Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -223,20 +223,21 @@ jobs:
fi
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
- name: Activate release job kinds after fleet replacement
- name: Activate release capabilities after fleet replacement
id: activate
# Migration 0020 stages new job kinds with an infinite
# run_after. This runs only after flyctl deploy has replaced the managed
# fleet, so no pre-capability worker can claim them. It retires the
# quiesced escalation-email state, backfills billing contacts, then
# atomically releases every staged supported job. A
# activates private-review author enforcement, then atomically releases
# every staged supported job. A
# failure leaves jobs safely staged and fails the workflow for a retry.
timeout-minutes: 5
run: |
set -euo pipefail
machines=$(flyctl machine list --app postil-web --json)
if ! fleet_summary=$(jq -ce -f scripts/verify-managed-fleet.jq <<<"${machines}"); then
echo "::error::Refusing to activate release job kinds: fleet image, process, or hosted-inference configuration is inconsistent."
echo "::error::Refusing to activate release capabilities: fleet image, process, or hosted-inference configuration is inconsistent."
exit 1
fi
echo "managed fleet verified: ${fleet_summary}"
Expand Down
7 changes: 5 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,11 @@ complete post-trial entitlement state idempotently through
`scripts/set-org-entitlement.ts`; the billing page reports the stored state and
lets organization administrators set the hosted overage hard cap. BYOK billing
copy directs administrators to provider-side budgets because Postil cannot
enforce external charges. The page does not represent a payment checkout. Review rows snapshot the pull request
author GitHub ID and login supplied by the reviewable pull-request webhook.
enforce external charges. The page does not represent a payment checkout. Before
a private review row can run, the worker resolves the pull request's author ID,
login, head, and base from GitHub. A rollout-activated database trigger rejects
anonymous active reviews and makes the recorded author identity immutable.
Historical rows remain unknown rather than receiving a guessed identity.
Billing counts distinct GitHub author IDs on private pull requests within the
entitlement period; bot and service identities count by the same ID rule.

Expand Down
48 changes: 48 additions & 0 deletions drizzle/0030_private_review_author_invariant.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
CREATE OR REPLACE FUNCTION enforce_private_review_author_identity() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM repositories
WHERE repositories.id = NEW.repository_id
AND repositories.private = true
) THEN
RETURN NEW;
END IF;

PERFORM pg_advisory_xact_lock(hashtextextended('postil:private-review-author-v1', 0));
IF NOT EXISTS (
SELECT 1
FROM deployment_capabilities
WHERE deployment_capabilities.name = 'private-review-author-v1'
) THEN
RETURN NEW;
END IF;

IF TG_OP = 'UPDATE'
AND (
OLD.author_github_id IS DISTINCT FROM NEW.author_github_id
OR OLD.author_login IS DISTINCT FROM NEW.author_login
) THEN
RAISE EXCEPTION 'private review author identity is immutable';
END IF;

IF NEW.status IN ('queued', 'running', 'completed')
AND (
NEW.author_github_id IS NULL
OR NEW.author_github_id <= 0
OR NEW.author_github_id > 9007199254740991
OR NEW.author_login IS NULL
OR length(btrim(NEW.author_login)) = 0
OR length(NEW.author_login) > 100
) THEN
RAISE EXCEPTION 'private review author identity is required';
END IF;
RETURN NEW;
END;
$$;
--> statement-breakpoint
CREATE TRIGGER reviews_private_author_identity_required
BEFORE INSERT OR UPDATE OF status, repository_id, author_github_id, author_login ON reviews
FOR EACH ROW EXECUTE FUNCTION enforce_private_review_author_identity();
7 changes: 7 additions & 0 deletions drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,13 @@
"when": 1784297400000,
"tag": "0029_review_trigger_provenance",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1784394144533,
"tag": "0030_private_review_author_invariant",
"breakpoints": true
}
]
}
8 changes: 7 additions & 1 deletion scripts/activate-release-jobs.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { closeDb, getDb, getPool } from "@/lib/db";
import { finalizeEscalationEmailRetirement } from "@/lib/escalation-email-retirement";
import { activateReleaseJobs } from "@/lib/release-job-rollout";
import {
activatePrivateReviewAuthorIdentity,
activateReleaseJobs,
} from "@/lib/release-job-rollout";
import { backfillBillingContactVerification } from "./backfill-billing-contact-verification";

async function main(): Promise<void> {
Expand All @@ -12,9 +15,12 @@ async function main(): Promise<void> {
const billing = await backfillBillingContactVerification(getDb(), {
confirm: true,
});
const privateReviewAuthorActivated =
await activatePrivateReviewAuthorIdentity(getPool());
const released = await activateReleaseJobs(getPool());
console.log(
`release job kinds activated: released=${released} ` +
`private_review_author=${privateReviewAuthorActivated ? "activated" : "already_active"} ` +
`billing_pending=${billing.pending} billing_queued=${billing.queued} ` +
`escalation_terminalized=${retirement.terminalized} ` +
`escalation_redacted=${retirement.redacted} ` +
Expand Down
8 changes: 6 additions & 2 deletions src/lib/github/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,11 +295,15 @@ export async function getPullRequestReviewContext(
if (!headSha || !baseSha) {
throw new Error(`GitHub pull request ${repoFullName}#${number} has incomplete refs`);
}
const authorGithubId = data.user?.id;
const authorLogin = typeof data.user?.login === "string" ? data.user.login.trim() : undefined;
return {
headSha,
baseSha,
draft: data.draft === true,
...(typeof data.user?.id === "number" ? { authorGithubId: data.user.id } : {}),
...(data.user?.login ? { authorLogin: data.user.login } : {}),
...(typeof authorGithubId === "number" && Number.isSafeInteger(authorGithubId) && authorGithubId > 0
? { authorGithubId }
: {}),
...(authorLogin && authorLogin.length <= 100 ? { authorLogin } : {}),
};
}
51 changes: 51 additions & 0 deletions src/lib/release-job-rollout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,57 @@ export const RELEASE_V1_JOB_KINDS = [

const CAPABILITY = "release-v1-jobs";
const ADVISORY_LOCK_NAME = "postil:release-v1-jobs";
export const PRIVATE_REVIEW_AUTHOR_CAPABILITY = "private-review-author-v1";
const PRIVATE_REVIEW_AUTHOR_LOCK = "postil:private-review-author-v1";

/** Activate private-review author enforcement after every managed process runs compatible code. */
export async function activatePrivateReviewAuthorIdentity(
pool: Pool,
): Promise<boolean> {
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query(
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
[PRIVATE_REVIEW_AUTHOR_LOCK],
);
const anonymousActive = await client.query<{ blocked: boolean }>(
`SELECT EXISTS (
SELECT 1
FROM reviews
JOIN repositories ON repositories.id = reviews.repository_id
WHERE repositories.private = true
AND reviews.status IN ('queued', 'running')
AND (
reviews.author_github_id IS NULL
OR reviews.author_github_id <= 0
OR reviews.author_github_id > 9007199254740991
OR reviews.author_login IS NULL
OR length(btrim(reviews.author_login)) = 0
OR length(reviews.author_login) > 100
)
) AS blocked`,
);
if (anonymousActive.rows[0]?.blocked) {
throw new Error(
"private review author enforcement has anonymous active reviews",
);
}
const activated = await client.query(
`INSERT INTO deployment_capabilities (name)
VALUES ($1)
ON CONFLICT (name) DO NOTHING`,
[PRIVATE_REVIEW_AUTHOR_CAPABILITY],
);
await client.query("COMMIT");
return (activated.rowCount ?? 0) > 0;
} catch (error) {
await client.query("ROLLBACK").catch(() => undefined);
throw error;
} finally {
client.release();
}
}

/**
* Activate new job consumers after the deploy has replaced every old
Expand Down
30 changes: 28 additions & 2 deletions src/worker/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
completeCheckRun,
createCheckRun,
findCheckRunByExternalId,
getPullRequestReviewContext,
} from "@/lib/github/checks";
import {
materializeOrgConfig,
Expand Down Expand Up @@ -605,6 +606,31 @@ export async function runReviewJob(
return;
}

let authorGithubId = payload.authorGithubId;
let authorLogin = payload.authorLogin;
if (currentRepository.private) {
const context = await translateWorkerAbort(
getPullRequestReviewContext(token, currentRepository.full_name, payload.prNumber, signal),
signal,
);
if (context.headSha !== payload.headSha || context.baseSha !== payload.baseSha) {
console.warn(
`review job skipped: ${currentRepository.full_name}#${payload.prNumber} refs changed before author verification`,
);
return;
}
if (
typeof context.authorGithubId !== "number" ||
!Number.isSafeInteger(context.authorGithubId) ||
context.authorGithubId <= 0 ||
!context.authorLogin
) {
throw new OperationalError("private review author identity is unavailable");
}
authorGithubId = context.authorGithubId;
authorLogin = context.authorLogin;
}

const hostedReviewUnavailable = !llm.byok && !hostedInferenceEnabled();

// Incremental re-review: baseline = last completed review of this PR.
Expand Down Expand Up @@ -636,8 +662,8 @@ export async function runReviewJob(
const reviewValues = {
repositoryId: repository.id,
prNumber: payload.prNumber,
authorGithubId: payload.authorGithubId ?? null,
authorLogin: payload.authorLogin ?? null,
authorGithubId: authorGithubId ?? null,
authorLogin: authorLogin ?? null,
headSha: payload.headSha,
baseSha: payload.baseSha,
sinceSha: baseline?.headSha ?? null,
Expand Down
31 changes: 31 additions & 0 deletions tests/github-checks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,37 @@ describe("pull-request review context", () => {
});
});

test("normalizes only a complete bounded author identity", async () => {
globalThis.fetch = (async (_input) =>
Response.json({
draft: false,
head: { sha: "head-sha" },
base: { sha: "base-sha" },
user: { id: 0, login: " " },
})) as typeof fetch;

await expect(getPullRequestReviewContext("token", "octo/repo", 7)).resolves.toEqual({
headSha: "head-sha",
baseSha: "base-sha",
draft: false,
});

globalThis.fetch = (async (_input) =>
Response.json({
draft: false,
head: { sha: "head-sha" },
base: { sha: "base-sha" },
user: { id: 42, login: ` ${"a".repeat(101)} ` },
})) as typeof fetch;

await expect(getPullRequestReviewContext("token", "octo/repo", 7)).resolves.toEqual({
headSha: "head-sha",
baseSha: "base-sha",
draft: false,
authorGithubId: 42,
});
});

test("fails closed when either immutable ref is absent", async () => {
globalThis.fetch = (async (_input) =>
Response.json({ head: { sha: "head-sha" }, base: {} })) as typeof fetch;
Expand Down
Loading
Loading