From ae61f2e3bcbceed6b145aef17f4983b65f3a980f Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sun, 18 Oct 2026 02:14:35 +0200 Subject: [PATCH 1/6] fix(ci): fail draft-mode proof once the live PR is ready A draft push and a ready_for_review transition seconds apart can be queued out of order: the draft run cancels the confirmation run, then passes `proof` with every product job unselected, leaving a green draft check as the ready PR's latest proof. The draft cadence now reads the live PR and refuses when it is ready, closed or at another head, with the same confirmation-required action as ready feedback. Unreadable live state fails closed. Refs #2363 Co-Authored-By: Claude Opus 5.5 --- .../work-issue/references/ci-and-merge.md | 2 ++ docs/decisions/ci-utility.md | 5 +++++ scripts/candidate-proof.ts | 18 +++++++++++++++++- tests/candidate-proof.test.ts | 14 ++++++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/.agents/skills/work-issue/references/ci-and-merge.md b/.agents/skills/work-issue/references/ci-and-merge.md index 24fc8cc31..f32749306 100644 --- a/.agents/skills/work-issue/references/ci-and-merge.md +++ b/.agents/skills/work-issue/references/ci-and-merge.md @@ -32,6 +32,8 @@ runtimes, failures/cancellations and unexpected skips leave proof unfinished. Keep drafts cheap and batch pushes. Ready synchronize runs fast feedback and leaves `proof` nonpassing; convert to draft and mark ready after the final push to request candidate confirmation. Open/reopen ready events also request it. +A draft run whose push raced the ready transition fails `proof` once the PR is +ready; request confirmation again rather than reading it as a stale success. The candidate gate checks actual merge parents/tree against the live head/base. A changed base requires a fresh confirmation event even without a rebase; valid immutable browser/layout retention remains available. Endurance is fresh diff --git a/docs/decisions/ci-utility.md b/docs/decisions/ci-utility.md index e9979186e..0f41e7c75 100644 --- a/docs/decisions/ci-utility.md +++ b/docs/decisions/ci-utility.md @@ -103,6 +103,11 @@ proof and endurance wait for confirmation. Its `proof` check deliberately fails with the action needed: convert to draft, finish pushes, then mark ready again. Fast feedback being green is useful, but never sufficient merge evidence. The request belongs to one event, not a label that can approve later commits. +A draft run's `proof` passes only while the live PR is still a draft at the +same head. GitHub can queue a draft push's run after the `ready_for_review` +run and cancel it, so the event payload alone cannot keep a draft success from +standing as a ready PR's latest proof +([#2363](https://github.com/schlessera/brain-kit/issues/2363)). Main pushes still execute complete affected proof. Scheduled and manual runs execute exhaustive discovery with a distinct aggregate check name, so they cannot impersonate a PR's candidate confirmation. Title/label edits run the separate contract gate, not long diff --git a/scripts/candidate-proof.ts b/scripts/candidate-proof.ts index d92a28b86..9f3c26585 100644 --- a/scripts/candidate-proof.ts +++ b/scripts/candidate-proof.ts @@ -3,10 +3,13 @@ import { planFromEnvironment, proofCadence, proofSelections } from "./ci-plan"; import { checkout, type Checkout } from "./proof-inputs"; import { GitHubProofAPI, type API } from "./proof-reuse"; +const CONFIRMATION_REQUIRED = "Candidate confirmation required: convert this PR to draft, then mark ready for review after the final push."; + export async function validateCandidate(root: string, event: any, env: NodeJS.ProcessEnv, api?: API, actual: Checkout = checkout(root)): Promise { const cadence = proofCadence(env, event); - if (cadence === "feedback") throw new Error("Candidate confirmation required: convert this PR to draft, then mark ready for review after the final push."); + if (cadence === "feedback") throw new Error(CONFIRMATION_REQUIRED); + if (cadence === "draft") return requireLiveDraft(event, api); if (cadence !== "confirmation") return; const pr = event.pull_request; if (!Number.isSafeInteger(pr?.number) || pr.number < 1 || !pr.base?.ref || !pr.head?.sha || !pr.base?.sha || @@ -28,6 +31,19 @@ export async function validateCandidate(root: string, event: any, env: NodeJS.Pr console.log(`BRAIN_CANDIDATE_RECEIPT ${JSON.stringify({ event: event.action, head: pr.head.sha, base: pr.base.sha, checkout: actual })}`); } +/** Draft gates pass only while the PR is still a draft at this head. A draft run + * queued after the ready event cancels its confirmation run, so the payload alone + * cannot tell a cheap draft from a ready PR (#2363). */ +async function requireLiveDraft(event: any, api?: API): Promise { + const pr = event.pull_request; + if (!Number.isSafeInteger(pr?.number) || pr.number < 1 || !pr.head?.sha) throw new Error("Draft proof is missing its PR identity"); + if (!api) throw new Error("Draft proof requires live read-only GitHub readiness verification"); + const live = await api.json(`pulls/${pr.number}`); + if (live.state !== "open" || live.draft !== true || live.head?.sha !== pr.head.sha) { + throw new Error(`Draft proof is stale: the live PR is ready, closed or at another head. ${CONFIRMATION_REQUIRED}`); + } +} + export function validateSelections(root: string, event: any, env: NodeJS.ProcessEnv, outputs: Record | undefined): void { const cadence = proofCadence(env, event); if (outputs?.cadence !== cadence) throw new Error("Candidate selection cadence is missing or inconsistent"); diff --git a/tests/candidate-proof.test.ts b/tests/candidate-proof.test.ts index ef2d4838c..49f9e764e 100644 --- a/tests/candidate-proof.test.ts +++ b/tests/candidate-proof.test.ts @@ -36,6 +36,20 @@ test("intermediate green feedback cannot satisfy final candidate confirmation", await validateCandidate("", event, env, api(), actual); }); +test("a draft-mode run cannot pass once the live PR is ready, even when queued after the ready event", async () => { + const draft = { ...event, action: "synchronize", pull_request: { ...event.pull_request, draft: true } }; + const live = { state: "open", draft: true, head: event.pull_request.head }; + await validateCandidate("", draft, env, api({ "pulls/7": live }), actual); + await expect(validateCandidate("", draft, env, api(), actual)).rejects.toThrow("Draft proof is stale"); + await expect(validateCandidate("", draft, env, api(), actual)).rejects.toThrow("Candidate confirmation required"); + for (const change of [{ head: { sha: "e".repeat(40) } }, { state: "closed" }, { draft: undefined }]) + await expect(validateCandidate("", draft, env, api({ "pulls/7": { ...live, ...change } }), actual)).rejects.toThrow("Draft proof is stale"); + const unreadable: API = { async json() { throw new Error("Proof API refused 503"); }, async log() { throw new Error("Unexpected log"); } }; + await expect(validateCandidate("", draft, env, unreadable, actual)).rejects.toThrow("Proof API refused 503"); + await expect(validateCandidate("", draft, env, undefined, actual)).rejects.toThrow("live read-only GitHub readiness"); + await expect(validateCandidate("", { ...draft, pull_request: { ...draft.pull_request, number: 0 } }, env, api({ "pulls/7": live }), actual)).rejects.toThrow("missing its PR identity"); +}); + test("candidate gate rejects changed head, base, readiness, immutable tree and missing evidence", async () => { for (const live of [ { state: "open", draft: false, head: { sha: "e".repeat(40) } }, From 7b65f97d159ada358be8f52b107841aaee0de6d5 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sun, 18 Oct 2026 02:20:37 +0200 Subject: [PATCH 2/6] test(ci): cover every draft action and the hosted draft-proof entry point Refs #2363 Co-Authored-By: Claude Opus 5.5 --- tests/candidate-proof.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/candidate-proof.test.ts b/tests/candidate-proof.test.ts index 49f9e764e..4bb4f8a49 100644 --- a/tests/candidate-proof.test.ts +++ b/tests/candidate-proof.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { proofCadence, planChecks, proofSelections } from "../scripts/ci-plan"; -import { validateCandidate } from "../scripts/candidate-proof"; +import { requireCandidate, validateCandidate } from "../scripts/candidate-proof"; import type { API } from "../scripts/proof-reuse"; const actual = { sha: "a".repeat(40), tree: "b".repeat(40), parents: ["c".repeat(40), "d".repeat(40)] }; @@ -39,8 +39,10 @@ test("intermediate green feedback cannot satisfy final candidate confirmation", test("a draft-mode run cannot pass once the live PR is ready, even when queued after the ready event", async () => { const draft = { ...event, action: "synchronize", pull_request: { ...event.pull_request, draft: true } }; const live = { state: "open", draft: true, head: event.pull_request.head }; - await validateCandidate("", draft, env, api({ "pulls/7": live }), actual); - await expect(validateCandidate("", draft, env, api(), actual)).rejects.toThrow("Draft proof is stale"); + for (const action of ["opened", "reopened", "synchronize", "converted_to_draft"]) { + await validateCandidate("", { ...draft, action }, env, api({ "pulls/7": live }), actual); + await expect(validateCandidate("", { ...draft, action }, env, api(), actual)).rejects.toThrow("Draft proof is stale"); + } await expect(validateCandidate("", draft, env, api(), actual)).rejects.toThrow("Candidate confirmation required"); for (const change of [{ head: { sha: "e".repeat(40) } }, { state: "closed" }, { draft: undefined }]) await expect(validateCandidate("", draft, env, api({ "pulls/7": { ...live, ...change } }), actual)).rejects.toThrow("Draft proof is stale"); @@ -48,6 +50,9 @@ test("a draft-mode run cannot pass once the live PR is ready, even when queued a await expect(validateCandidate("", draft, env, unreadable, actual)).rejects.toThrow("Proof API refused 503"); await expect(validateCandidate("", draft, env, undefined, actual)).rejects.toThrow("live read-only GitHub readiness"); await expect(validateCandidate("", { ...draft, pull_request: { ...draft.pull_request, number: 0 } }, env, api({ "pulls/7": live }), actual)).rejects.toThrow("missing its PR identity"); + await expect(validateCandidate("", { ...draft, pull_request: { ...draft.pull_request, head: {} } }, env, api({ "pulls/7": live }), actual)).rejects.toThrow("missing its PR identity"); + // The hosted aggregate's entry point reaches the same live check; without a token it cannot pass. + await expect(requireCandidate("", draft, { ...env, GITHUB_ACTIONS: "true", GH_TOKEN: "" })).rejects.toThrow("live read-only GitHub readiness"); }); test("candidate gate rejects changed head, base, readiness, immutable tree and missing evidence", async () => { From 603be36b05b38fad487cc2fe2e93c6672ad63028 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sun, 18 Oct 2026 02:21:48 +0200 Subject: [PATCH 3/6] fix(ci): name the draft aggregate apart from candidate proof The live readiness read is a snapshot: a PR marked ready between that read and the draft run's completion could still show a passing `proof` until its confirmation reported. Draft runs now report their aggregate as `draft gates`, so a `proof` row on a pull request only ever comes from feedback (which fails) or confirmation. The workflow guard pins the new name and rejects dropping the draft branch. The audited producer digest for ci.yml is refreshed for the one-line job-name change. Refs #2363 Co-Authored-By: Claude Opus 5.5 --- .agents/skills/work-issue/references/ci-and-merge.md | 5 +++-- .github/workflows/ci.yml | 2 +- docs/decisions/ci-utility.md | 8 ++++---- scripts/check-ci-workflows.ts | 2 +- scripts/generated-build-inputs.ts | 2 +- tests/ci-workflows.test.ts | 6 ++++-- 6 files changed, 14 insertions(+), 11 deletions(-) diff --git a/.agents/skills/work-issue/references/ci-and-merge.md b/.agents/skills/work-issue/references/ci-and-merge.md index f32749306..cb5f0f321 100644 --- a/.agents/skills/work-issue/references/ci-and-merge.md +++ b/.agents/skills/work-issue/references/ci-and-merge.md @@ -32,8 +32,9 @@ runtimes, failures/cancellations and unexpected skips leave proof unfinished. Keep drafts cheap and batch pushes. Ready synchronize runs fast feedback and leaves `proof` nonpassing; convert to draft and mark ready after the final push to request candidate confirmation. Open/reopen ready events also request it. -A draft run whose push raced the ready transition fails `proof` once the PR is -ready; request confirmation again rather than reading it as a stale success. +Draft runs report `draft gates`, not `proof`. A ready PR without a passing +`proof` from its confirmation run is unconfirmed, even if `draft gates` passed; +a draft run that raced the ready transition fails, so request confirmation again. The candidate gate checks actual merge parents/tree against the live head/base. A changed base requires a fresh confirmation event even without a rebase; valid immutable browser/layout retention remains available. Endurance is fresh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9776188d1..394f1906f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -470,7 +470,7 @@ jobs: retention-days: 7 proof: - name: ${{ github.event_name == 'pull_request' && 'proof' || 'integrated or exhaustive proof' }} + name: ${{ github.event_name != 'pull_request' && 'integrated or exhaustive proof' || github.event.pull_request.draft && 'draft gates' || 'proof' }} # Evaluate even after failures or skips; never interpret missing selected proof as green. needs: [changeset, verify, pack, unit, runtime, runtime_host, browser, layout, captures, endurance] if: ${{ always() && !cancelled() }} diff --git a/docs/decisions/ci-utility.md b/docs/decisions/ci-utility.md index 0f41e7c75..4291b6d3b 100644 --- a/docs/decisions/ci-utility.md +++ b/docs/decisions/ci-utility.md @@ -103,10 +103,10 @@ proof and endurance wait for confirmation. Its `proof` check deliberately fails with the action needed: convert to draft, finish pushes, then mark ready again. Fast feedback being green is useful, but never sufficient merge evidence. The request belongs to one event, not a label that can approve later commits. -A draft run's `proof` passes only while the live PR is still a draft at the -same head. GitHub can queue a draft push's run after the `ready_for_review` -run and cancel it, so the event payload alone cannot keep a draft success from -standing as a ready PR's latest proof +A draft run's aggregate is named `draft gates`, never `proof`, so its cheap +success cannot stand as a ready PR's proof. GitHub can queue a draft push's run +after the `ready_for_review` run and cancel it; the draft aggregate therefore +also reads the live PR and fails once it is ready, closed or at another head ([#2363](https://github.com/schlessera/brain-kit/issues/2363)). Main pushes still execute complete affected proof. Scheduled and manual runs execute exhaustive discovery with a distinct aggregate check name, so they diff --git a/scripts/check-ci-workflows.ts b/scripts/check-ci-workflows.ts index d0de95bc1..e66067b8b 100644 --- a/scripts/check-ci-workflows.ts +++ b/scripts/check-ci-workflows.ts @@ -64,7 +64,7 @@ export function workflowProblems(text: string, file: string): string[] { !job?.steps?.some((step: any) => step.run === `bun scripts/ci-proof.ts ${domain} --shard=\${{ matrix.shard }}/${PROOF_SHARDS[domain]}`)) fail(`${domain}: matrix and shard divisor must match PROOF_SHARDS`); } const proof = workflow.jobs?.proof; - if (proof?.name !== "${{ github.event_name == 'pull_request' && 'proof' || 'integrated or exhaustive proof' }}") fail("proof: manual or main runs must not impersonate candidate confirmation"); + if (proof?.name !== "${{ github.event_name != 'pull_request' && 'integrated or exhaustive proof' || github.event.pull_request.draft && 'draft gates' || 'proof' }}") fail("proof: draft, manual or main runs must not impersonate candidate confirmation"); if (JSON.stringify(proof?.needs) !== JSON.stringify(["changeset", ...names])) fail("proof: aggregate must observe every selected category"); if (proof?.if !== "${{ always() && !cancelled() }}") fail("proof: aggregate must observe failures/skips and reject cancellation"); for (const category of ["unit", "runtime", "runtime_host", "browser", "layout", "captures", "endurance"]) { diff --git a/scripts/generated-build-inputs.ts b/scripts/generated-build-inputs.ts index de8204024..8c25e958b 100644 --- a/scripts/generated-build-inputs.ts +++ b/scripts/generated-build-inputs.ts @@ -15,7 +15,7 @@ const auditedProducer: Record = { "scripts/ci-proof.ts": "c58de06eba788b6b892416ddda3abb28fa1e8e908f25c0800482fbe05e0de678", "scripts/check-pr.ts": "ed29d8dbb2e8b9982b502ad663045992339088dd8db766335b0b7ed2ba2436f6", "scripts/ci-runner.ts": "e6b9da9fdf92cb7ade402c620a8f58727bf5a09725bed03000a862866a6f9262", - ".github/workflows/ci.yml": "0664eae990558d400c8b34b8d1fe9d687ff2b9e82e906ad2480e10bf42d9fc32", + ".github/workflows/ci.yml": "d096c232127fa314ca063d35095956fd95c3bc63e71adad939ecb92d841f4100", "package.json": "0d2ba2667eed49f4b686e57e54578d5f51e6c1b93605de0e1f5c8d08f8d0b188" }; export function generatedCssInputs(root: string, artifact: string): { entry: string; producer: string[] } | undefined { diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index fbb794b9d..c3b0322c0 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -342,10 +342,12 @@ test("workflow guard rejects an omitted complete proof command and aggregate pre }); -test("workflow cannot check out a different tree or impersonate candidate proof from manual runs", () => { +test("workflow cannot check out a different tree or impersonate candidate proof from draft or manual runs", () => { const text = readFileSync(join(ROOT, ".github/workflows/ci.yml"), "utf8"); expect(workflowProblems(text.replace("persist-credentials: false", "persist-credentials: false\n ref: main"), "ci.yml").join("\n")).toContain("proof checkout must use the event candidate"); - expect(workflowProblems(text.replace("name: ${{ github.event_name == 'pull_request' && 'proof' || 'integrated or exhaustive proof' }}", "name: proof"), "ci.yml").join("\n")).toContain("must not impersonate candidate confirmation"); + expect(workflowProblems(text.replace("name: ${{ github.event_name != 'pull_request' && 'integrated or exhaustive proof' || github.event.pull_request.draft && 'draft gates' || 'proof' }}", "name: proof"), "ci.yml").join("\n")).toContain("must not impersonate candidate confirmation"); + // A draft run's aggregate keeps a distinct name, so its cheap success never stands as a ready PR's proof (#2363). + expect(workflowProblems(text.replace("github.event.pull_request.draft && 'draft gates' || ", ""), "ci.yml").join("\n")).toContain("must not impersonate candidate confirmation"); }); test("browser and layout matrices match the shared shard table (#2414)", () => { From 6a821b5b533f495909c06f7ca551fa7e64ce36fd Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sun, 18 Oct 2026 02:29:31 +0200 Subject: [PATCH 4/6] test(ci): drive the draft readiness check through its hosted callers Cover a present token through the real API client, malformed live state, and the emitted aggregate refusing a draft run it cannot verify. State in the docs that the live read only fails when it observes readiness; the distinct check name covers a later transition. Refs #2363 Co-Authored-By: Claude Opus 5.5 --- .../work-issue/references/ci-and-merge.md | 4 +- docs/decisions/ci-utility.md | 6 ++- tests/candidate-proof.test.ts | 52 ++++++++++++++++++- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/.agents/skills/work-issue/references/ci-and-merge.md b/.agents/skills/work-issue/references/ci-and-merge.md index cb5f0f321..b9e9aeaf5 100644 --- a/.agents/skills/work-issue/references/ci-and-merge.md +++ b/.agents/skills/work-issue/references/ci-and-merge.md @@ -33,8 +33,8 @@ Keep drafts cheap and batch pushes. Ready synchronize runs fast feedback and leaves `proof` nonpassing; convert to draft and mark ready after the final push to request candidate confirmation. Open/reopen ready events also request it. Draft runs report `draft gates`, not `proof`. A ready PR without a passing -`proof` from its confirmation run is unconfirmed, even if `draft gates` passed; -a draft run that raced the ready transition fails, so request confirmation again. +`proof` from its confirmation run is unconfirmed, even if `draft gates` passed +or failed because its live read found the PR ready; request confirmation again. The candidate gate checks actual merge parents/tree against the live head/base. A changed base requires a fresh confirmation event even without a rebase; valid immutable browser/layout retention remains available. Endurance is fresh diff --git a/docs/decisions/ci-utility.md b/docs/decisions/ci-utility.md index 4291b6d3b..8d81ecaa3 100644 --- a/docs/decisions/ci-utility.md +++ b/docs/decisions/ci-utility.md @@ -105,8 +105,10 @@ Fast feedback being green is useful, but never sufficient merge evidence. The request belongs to one event, not a label that can approve later commits. A draft run's aggregate is named `draft gates`, never `proof`, so its cheap success cannot stand as a ready PR's proof. GitHub can queue a draft push's run -after the `ready_for_review` run and cancel it; the draft aggregate therefore -also reads the live PR and fails once it is ready, closed or at another head +after the `ready_for_review` run and cancel it, leaving no `proof` at all. The +draft aggregate also reads the live PR and fails when that read finds it ready, +closed or at another head, which turns the cancelled request into a visible +failure; a transition after the read is still covered by the distinct name ([#2363](https://github.com/schlessera/brain-kit/issues/2363)). Main pushes still execute complete affected proof. Scheduled and manual runs execute exhaustive discovery with a distinct aggregate check name, so they diff --git a/tests/candidate-proof.test.ts b/tests/candidate-proof.test.ts index 4bb4f8a49..fc3f554a3 100644 --- a/tests/candidate-proof.test.ts +++ b/tests/candidate-proof.test.ts @@ -1,5 +1,10 @@ import { expect, test } from "bun:test"; -import { proofCadence, planChecks, proofSelections } from "../scripts/ci-plan"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { planFromEnvironment, proofCadence, planChecks, proofSelections } from "../scripts/ci-plan"; +import { PROOF_JOBS } from "../scripts/ci-proof"; +import { git } from "../scripts/proof-inputs"; import { requireCandidate, validateCandidate } from "../scripts/candidate-proof"; import type { API } from "../scripts/proof-reuse"; @@ -44,7 +49,7 @@ test("a draft-mode run cannot pass once the live PR is ready, even when queued a await expect(validateCandidate("", { ...draft, action }, env, api(), actual)).rejects.toThrow("Draft proof is stale"); } await expect(validateCandidate("", draft, env, api(), actual)).rejects.toThrow("Candidate confirmation required"); - for (const change of [{ head: { sha: "e".repeat(40) } }, { state: "closed" }, { draft: undefined }]) + for (const change of [{ head: { sha: "e".repeat(40) } }, { state: "closed" }, { state: undefined }, { state: "merged" }, { draft: undefined }]) await expect(validateCandidate("", draft, env, api({ "pulls/7": { ...live, ...change } }), actual)).rejects.toThrow("Draft proof is stale"); const unreadable: API = { async json() { throw new Error("Proof API refused 503"); }, async log() { throw new Error("Unexpected log"); } }; await expect(validateCandidate("", draft, env, unreadable, actual)).rejects.toThrow("Proof API refused 503"); @@ -67,3 +72,46 @@ test("candidate gate rejects changed head, base, readiness, immutable tree and m await expect(validateCandidate("", event, env, api(), { ...actual, parents: [...actual.parents].reverse() })).rejects.toThrow("checkout does not match"); await expect(validateCandidate("", event, { ...env, GITHUB_SHA: "e".repeat(40) }, api(), actual)).rejects.toThrow("checkout does not match"); }); + +test("the hosted entry point reads live readiness through its own API client", async () => { + const draft = { ...event, action: "synchronize", pull_request: { ...event.pull_request, draft: true } }; + const hosted = { ...env, GITHUB_ACTIONS: "true", GH_TOKEN: "fixture-token" }; + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "fetch")!; + const reads: string[] = []; + let live: unknown = { state: "open", draft: true, head: event.pull_request.head }; + Object.defineProperty(globalThis, "fetch", { ...descriptor, value: async (url: string) => { + reads.push(url); + return new Response(JSON.stringify(live), { status: 200 }); + } }); + try { + await requireCandidate("", draft, hosted); + live = { state: "open", draft: false, head: event.pull_request.head }; + await expect(requireCandidate("", draft, hosted)).rejects.toThrow("Draft proof is stale"); + } finally { + Object.defineProperty(globalThis, "fetch", descriptor); + } + expect(reads).toEqual(Array(2).fill(`https://api.github.com/repos/${env.GITHUB_REPOSITORY}/pulls/7`)); +}); + +test("the emitted hosted aggregate refuses a draft run it cannot verify as still draft", async () => { + const root = resolve(import.meta.dir, ".."); + const head = git(root, "rev-parse", "HEAD").trim(); + const draft = { action: "synchronize", pull_request: { number: 7, draft: true, head: { sha: head }, base: { sha: head, ref: "main" } } }; + const plan = planFromEnvironment(root, { GITHUB_EVENT_NAME: "pull_request", BASE_SHA: head, HEAD_SHA: head }); + plan.cadence = "draft"; + const selections = proofSelections(plan); + const needs = { changeset: { result: "success", outputs: { cadence: "draft", ...Object.fromEntries(Object.entries(selections).map(([name, on]) => [name, String(on)])) } }, + ...Object.fromEntries(PROOF_JOBS.map(name => [name, { result: selections[name] ? "success" : "skipped" }])) }; + const directory = mkdtempSync(join(tmpdir(), "draft-proof-event-")); + try { + writeFileSync(join(directory, "event.json"), JSON.stringify(draft)); + const child = Bun.spawn([process.execPath, "scripts/ci-proof.ts", "aggregate"], { cwd: root, + env: { ...process.env, GITHUB_ACTIONS: "true", GITHUB_EVENT_NAME: "pull_request", GITHUB_EVENT_PATH: join(directory, "event.json"), + GITHUB_REPOSITORY: env.GITHUB_REPOSITORY, GITHUB_SHA: head, GH_TOKEN: "", PROOF_NEEDS: JSON.stringify(needs) }, stdout: "pipe", stderr: "pipe" }); + const [code, err] = await Promise.all([child.exited, new Response(child.stderr).text()]); + expect(code, err).toBe(1); + expect(err).toContain("Draft proof requires live read-only GitHub readiness verification"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); From e1c3ec76f6a5744ec7b21496f6ab70097e10986e Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sun, 18 Oct 2026 02:57:31 +0200 Subject: [PATCH 5/6] test(ci): drop the spawned draft aggregate control Planning the whole repository twice (fixture outputs, then the child's selection check) took 20 s locally and timed out at 30 s on a hosted unit shard. A cheap version needs the aggregate to validate the candidate before planning, which changes a pinned producer; #2400 tracks it. The present-token entry-point control remains. Refs #2363 Co-Authored-By: Claude Opus 5.5 --- tests/candidate-proof.test.ts | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/tests/candidate-proof.test.ts b/tests/candidate-proof.test.ts index fc3f554a3..e4d564823 100644 --- a/tests/candidate-proof.test.ts +++ b/tests/candidate-proof.test.ts @@ -1,10 +1,5 @@ import { expect, test } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { planFromEnvironment, proofCadence, planChecks, proofSelections } from "../scripts/ci-plan"; -import { PROOF_JOBS } from "../scripts/ci-proof"; -import { git } from "../scripts/proof-inputs"; +import { proofCadence, planChecks, proofSelections } from "../scripts/ci-plan"; import { requireCandidate, validateCandidate } from "../scripts/candidate-proof"; import type { API } from "../scripts/proof-reuse"; @@ -92,26 +87,3 @@ test("the hosted entry point reads live readiness through its own API client", a } expect(reads).toEqual(Array(2).fill(`https://api.github.com/repos/${env.GITHUB_REPOSITORY}/pulls/7`)); }); - -test("the emitted hosted aggregate refuses a draft run it cannot verify as still draft", async () => { - const root = resolve(import.meta.dir, ".."); - const head = git(root, "rev-parse", "HEAD").trim(); - const draft = { action: "synchronize", pull_request: { number: 7, draft: true, head: { sha: head }, base: { sha: head, ref: "main" } } }; - const plan = planFromEnvironment(root, { GITHUB_EVENT_NAME: "pull_request", BASE_SHA: head, HEAD_SHA: head }); - plan.cadence = "draft"; - const selections = proofSelections(plan); - const needs = { changeset: { result: "success", outputs: { cadence: "draft", ...Object.fromEntries(Object.entries(selections).map(([name, on]) => [name, String(on)])) } }, - ...Object.fromEntries(PROOF_JOBS.map(name => [name, { result: selections[name] ? "success" : "skipped" }])) }; - const directory = mkdtempSync(join(tmpdir(), "draft-proof-event-")); - try { - writeFileSync(join(directory, "event.json"), JSON.stringify(draft)); - const child = Bun.spawn([process.execPath, "scripts/ci-proof.ts", "aggregate"], { cwd: root, - env: { ...process.env, GITHUB_ACTIONS: "true", GITHUB_EVENT_NAME: "pull_request", GITHUB_EVENT_PATH: join(directory, "event.json"), - GITHUB_REPOSITORY: env.GITHUB_REPOSITORY, GITHUB_SHA: head, GH_TOKEN: "", PROOF_NEEDS: JSON.stringify(needs) }, stdout: "pipe", stderr: "pipe" }); - const [code, err] = await Promise.all([child.exited, new Response(child.stderr).text()]); - expect(code, err).toBe(1); - expect(err).toContain("Draft proof requires live read-only GitHub readiness verification"); - } finally { - rmSync(directory, { recursive: true, force: true }); - } -}); From dbec504b2d9838052af677bcb9cf48428f8f0368 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sun, 18 Oct 2026 05:17:46 +0200 Subject: [PATCH 6/6] fix(ci): keep the audited ci.yml producer digest #2388 binds generated-CSS positive fixtures to producer bytes frozen at the audited revision, and its ruling says legitimate drift requires fresh proof rather than redefining the audit. Refreshing the ci.yml digest here made six frozen fixtures fail. The job-name change now leaves the binding to fresh proof; #2408 asks how re-audits happen. Refs #2363 Co-Authored-By: Claude Opus 5.5 --- scripts/generated-build-inputs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/generated-build-inputs.ts b/scripts/generated-build-inputs.ts index 8c25e958b..de8204024 100644 --- a/scripts/generated-build-inputs.ts +++ b/scripts/generated-build-inputs.ts @@ -15,7 +15,7 @@ const auditedProducer: Record = { "scripts/ci-proof.ts": "c58de06eba788b6b892416ddda3abb28fa1e8e908f25c0800482fbe05e0de678", "scripts/check-pr.ts": "ed29d8dbb2e8b9982b502ad663045992339088dd8db766335b0b7ed2ba2436f6", "scripts/ci-runner.ts": "e6b9da9fdf92cb7ade402c620a8f58727bf5a09725bed03000a862866a6f9262", - ".github/workflows/ci.yml": "d096c232127fa314ca063d35095956fd95c3bc63e71adad939ecb92d841f4100", + ".github/workflows/ci.yml": "0664eae990558d400c8b34b8d1fe9d687ff2b9e82e906ad2480e10bf42d9fc32", "package.json": "0d2ba2667eed49f4b686e57e54578d5f51e6c1b93605de0e1f5c8d08f8d0b188" }; export function generatedCssInputs(root: string, artifact: string): { entry: string; producer: string[] } | undefined {