Repository navigation
ci: judge a pull request from the base branch tree - #836
Conversation
Every evaluator that decides whether a pull request may merge was executed from the pull request's own checkout, so a T3 diff could edit the code that judges it. `review-gate` and `pr-lint` already checked the base ref out; the aggregate and supply-chain gates did not. `ci-ok`, `changes`, `risk`, `action-pins`, `workflow-lint` (the workflow-injection scanner), `codeql-required` and the CodeQL result gate now check the base branch out into `base/` and run `node base/scripts/<gate>.mjs`. Only the data under test comes from the head: the diff (`--repo .`), the workflow files, and the SARIF the analysis produced. The CodeQL baseline and the workflow-injection allowlist are read from `base/`, so a pull request cannot widen either. The `changes` job also runs `scripts/gate-integrity.mjs`, which writes every gate file in the diff into the job summary and fails closed when the diff cannot be read. Not moved: for a `pull_request` event GitHub fetches the workflow YAML from the pull request, so a job's own `run:` lines are the head's text. That shape is covered by the required check plus a T3 PASS on a diff the summary names; a `workflow_run` aggregate is a separate slice. Fixes #640
The first CI run on this branch failed in `changes`: the base branch (43210cf) predates `scripts/gate-integrity.mjs`, so `node base/scripts/gate-integrity.mjs` was MODULE_NOT_FOUND on the pull request that introduces the file. The step now tests the base path, `[ -f base/scripts/gate-integrity.mjs ]`, and writes a notice when it is absent. The guard reads the base tree, so a diff cannot decide whether the summary runs; once the file is on `main` every later pull request gets the summary. `test/gateBaseTree.test.ts` pins the base path, and `docs/autonomous-dev.md` says why the guard exists. Refs #640
A reviewer reading a run log could not tell which copy of `ci-ok` produced the verdict: the base checkout is a second directory, and the branch's own copy of the file still sits at the root. `ci-ok` now prints the blob the base tree holds for `scripts/ci-ok.mjs` before it runs it, so the sha in the log names the file the verdict came from, and a branch that rewrites the evaluator shows up as a mismatch in its own run. `docs/autonomous-dev.md` records the line and says it is evidence, not a gate. Refs #640
HugoXK-bot
left a comment
There was a problem hiding this comment.
Verdict: PASS at 5deaffa
Written-by: HugoXK-bot
Posted-by: HugoXK-bot
Read 5deaffa3b061eaf9f28726dab637f15eacfc68f5 (tree 82d00ee864ac4ed317f51964246206c2be9a7f35), base 43210cf8, 16 files +601/−19, T3 (risk-gate basis .github/workflows/ci.yml). Every check at this tip is green — ci-ok 11:39:04Z, codeql-required 11:39:02Z, test (24) 11:38:08Z, workflow-lint 11:37:10Z, pr-lint 11:36:52Z — so review-gate is red only because this PASS did not exist.
I re-ran both halves myself, in a worktree at the tip and in a second worktree at the base, rather than reading them from the body.
- The split is real.
.github/workflows/ci.yml:66-72(changes),:460-466(risk),:527-533(action-pins),:555-561(workflow-lint),:667-673(codeql-required),:772-778(ci-ok) each add a secondactions/checkoutwithref: github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.repository.default_branchandpath: base, and each executesnode base/scripts/<evaluator>.mjs(:113,:485,:539,:568,:688,:797)..github/workflows/codeql.yml:52-59,:66does the same forcodeql-gate.mjs, with the baseline read frombase/.github/agent/codeql-baseline.json, so the head cannot widen its own baseline. - The two evaluators that need head data take it as an argument instead of inferring it:
scripts/ci-changes.mjs:34-40andscripts/risk-gate.mjs:56-62(--repo),scripts/action-pins.mjs:10,17andscripts/workflow-injection.mjs:8,18-19(--workflows), with the injection allowlist read frombase/— a pull request can no longer allowlist its own expression. - Red first is genuine. At base
43210cf8with only the six test files copied in,npx tsx --test test/gateBaseTree.test.ts test/gateIntegrity.test.ts test/ciOk.test.ts test/ciChanges.test.ts test/codeqlRequired.test.ts test/supplyChain.test.tsgives 90 tests, 76 pass, 14 fail, exit 1: the six "evaluates from the base branch tree" tests, the CodeQL baseline test, the classifier test, theci-okinvocation test, the missingscripts/lib/gate-integrity.mjs, and the guard test — the same fourteen the body names. At the tip those files are green. - The two behavioural tests are the mechanism, not a restatement of the YAML text:
test/gateBaseTree.test.ts:82-105stubs the headscripts/ci-ok.mjstoprocess.exit(0)and shows it exits 0 over a failinglintwhile the base copy exits 1 naminglint;:107-150rewrites the headscripts/ciChanges.mjsto claimdocsOnlyand shows the base classifier still reads the head diff assource: true. - The
changesguard reads the base tree:.github/workflows/ci.yml:93-99tests[ -f base/scripts/gate-integrity.mjs ], andtest/gateBaseTree.test.ts:65-75pins the base path and forbids the head one. So the pull request that introduces the summary still runs the job, and a diff cannot decide whether the summary runs. - Live evidence, not a claim: probe #837 (
3afe6961, stubbedscripts/ci-ok.mjsplus a deliberatesrc/type error) is red in run43710167901withscripts/ci-ok.mjs from the base tree: blob 78095383…and noPROBE:line, while this PR's own run at the tip prints the base blob andci-ok: every required job passed (19 required). I did not touch #837.
Non-blocking, for the follow-up slice:
docs/autonomous-dev.md:346-348says "Every evaluator is checked out from the base branch" over a seven-row table, but five scripts that can fail a required job still execute from the head tree:.github/workflows/ci.yml:228(node scripts/coverage-diff.mjs --floor 80, andcoverageis inci-ok'sneedsat:765),:261and:514(dist-manifest.mjs),:435(changelog.mjs),:448(version.mjs),:515(restore-drill-ci.mjs). Either move them in the next slice or narrow that sentence to the seven jobs the table names; as written a reader believes the split is exhaustive.- The residual you document at
docs/autonomous-dev.md:382-386is the one that still matters: on apull_requestevent the workflow YAML is the head's copy, so a diff can still rewrite arun:line and skipbase/. The protection there is the T3 PASS plus thegate-integritysummary naming the gate files, not this checkout split. I accept that scoping because it is stated and becauseci-okat the tip still requires a reviewer PASS, but nothing later should describe #640 as fully closed.
Ran:
npm run checkat5deaffa3— pass, 2171/2171 tests, oxlint,tsc --noEmitand build cleannpx tsx --test test/gateBaseTree.test.ts test/gateIntegrity.test.ts test/ciOk.test.ts test/ciChanges.test.ts test/codeqlRequired.test.ts test/supplyChain.test.tsat base43210cf8— 90 tests, 76 pass, 14 fail (the red half)node scripts/gate-integrity.mjs --repo . --git-base 43210cf8aad25880f01cbbfb75e0dd5d8e84835d --git-head HEADat the tip — ok, 9 gate files in 16 changed pathsnode scripts/ci-changes.mjs --repo . --git-base 43210cf8aad25880f01cbbfb75e0dd5d8e84835d --git-head HEADat the tip —{"docsOnly":false,"source":true,"frontend":false,"deps":false,"db":false,"docker":true,"workflows":true,"codeql":true,"deploy":false}node scripts/action-pins.mjs --workflows .github/workflowsat the tip — ok, 74 pinned references across 8 filesnode scripts/workflow-injection.mjs --workflows .github/workflows --allowlist .github/agent/workflow-injection-allowlist.jsonat the tip — ok, 8 files, 2 allowlisted expressionsnode scripts/pr-lint.mjs --pr 836— ok (#836, T3, 16 files)
Not run:actionlintlocally (no binary on this box; the pinned step is green in CI at this tip), the live Docker/CodeQL analysis.
Conclusion: the six merge-deciding evaluators now execute from the base branch while the diff, the workflow files and the SARIF stay the head's, the attack shape is pinned by two behavioural tests that fail at the base and pass at the tip, and the change is T3 with every check green at the named tip — PASS at 5deaffa3b061eaf9f28726dab637f15eacfc68f5; the two notes above are follow-up scope, not conditions.
Written-by: HugoXK
Fixes #640
Tip:
5deaffa3b061eaf9f28726dab637f15eacfc68f5, tree82d00ee864ac4ed317f51964246206c2be9a7f35, base (this pull request'smain)43210cf8aad25880f01cbbfb75e0dd5d8e84835d.What was broken
Every check that decides whether a pull request may merge was executed from the pull request's own checkout.
ci-ok,codeql-required,action-pins,workflow-lintand the CodeQL result gate all rannode scripts/...from the head tree, so a T3 diff that touches.github/**orscripts/**could edit the code that judges it.review-gateandpr-lintalready checked the base ref out on purpose; these did not.What changed
.github/workflows/ci.yml— thechanges,risk,action-pins,workflow-lint,codeql-requiredandci-okjobs check the base ref out intobase/(github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.repository.default_branch) and runnode base/scripts/<gate>.mjs. The head checkout stays, because it holds the data under test..github/workflows/codeql.yml— the result gate runsbase/scripts/codeql-gate.mjswith--baseline base/.github/agent/codeql-baseline.json, so the baseline is the base copy too.scripts/ci-changes.mjsandscripts/risk-gate.mjs— gain--repo <dir>: the evaluator code is the base tree's, the diff is the head checkout's. The change classifier reads the head diff; its code does not.scripts/action-pins.mjsandscripts/workflow-injection.mjs— the workflow directory is an argument (--workflows .github/workflows), and the injection allowlist is an explicit path pointing atbase/.github/agent/workflow-injection-allowlist.json, so a pull request cannot allowlist its own expression.scripts/gate-integrity.mjs(new) andscripts/lib/gate-integrity.mjs(new) — thechangesjob writes agate-integritysummary naming every gate file in the diff. An unreadable diff exits 2 ("failing closed") instead of reporting a clean summary. Every path in the diff that lands under.github/workflows/,.github/agent/,.github/scripts/orscripts/is listed.ci-okprints the blob the base tree holds for the evaluator it is about to run —scripts/ci-ok.mjs from the base tree: blob <sha>— so a run log names the copy that decided instead of leaving a branch's rewritten copy invisible. It is evidence, not a gate: the verdict still comes from the base file the next step executes.docs/autonomous-dev.md— a "Gate integrity: the evaluator runs from the base branch" section with the per-job table, the blob-sha log line, and the residual shape below.Nothing about the merge requirement changes: a diff that edits a gate is still T3 and still needs a PASS at the tip. No required check is renamed.
What this does not move, and the docs say so: on a
pull_requestevent GitHub itself fetches the workflow YAML from the pull request, so a job's ownrun:lines are the head's text by construction. Moving the aggregate onto aworkflow_runworkflow (which always executes the default branch's copy) is a separate slice and is not part of this PR.Red first
At live main
43210cf8aad25880f01cbbfb75e0dd5d8e84835d, in a clean worktree with only the six test files from this tip copied in and the source untouched:npx tsx --test test/gateBaseTree.test.ts test/gateIntegrity.test.ts test/ciOk.test.ts test/ciChanges.test.ts test/codeqlRequired.test.ts test/supplyChain.test.ts— 90 tests, 76 pass, 14 fail, exit 1. The 14:changes/risk/action-pins/workflow-lint/codeql-required/ci-ok evaluates from the base branch tree, not the pull request head(6) — the job block has nobase/scripts/...call and nopath: basecheckout.the CodeQL result gate and its baseline come from the base branch—codeql-gate.mjsand the baseline are the head copies at the base.the change classifier reads the head diff while the classifier code comes from base— the classify step callsnode scripts/ci-changes.mjs.the classify step delegates the diff and keeps the every-gate fallback for other events— assertsnode base/scripts/ci-changes.mjsand the--allbranch that now also runs gate-integrity.the workflow runs the script and no longer accepts a skipped job inline— assertsnode base/scripts/ci-ok.mjs.codeql-required is in ci-ok needs...— assertsnode base/scripts/codeql-required.mjs.the workflow lint job runs actionlint and the injection checker— asserts the base invocation with--workflows/--allowlist.test/gateIntegrity.test.tsas a file —scripts/lib/gate-integrity.mjsdoes not exist at the base.the gate-integrity call is guarded against the base tree, so the pull request that introduces it still runs the job— thechangesjob does not test[ -f base/scripts/gate-integrity.mjs ]at the base.The two behavioural tests in
test/gateBaseTree.test.tspass at the base by design, because they build their ownbase/andhead/trees: one stubshead/scripts/ci-ok.mjstoprocess.exit(0)and shows the head copy exits 0 over a failinglintwhile the base copy exits 1 naminglint; the other rewriteshead/scripts/lib/ciChanges.mjsto claimdocsOnlyand shows the base classifier still reads the head diff assource: true. They are the mechanism, not the regression.Ran:
npm ci— 87 packages, exit 0.npm run checkat5deaffa3b061eaf9f28726dab637f15eacfc68f5— exit 0, 2171/2171 tests, lint clean,tsc --noEmitclean, build clean.43210cf8— 14 fail / 76 pass.node scripts/risk-gate.mjs --repo . --base 43210cf8…—risk-tier: T3; basis: .github/workflows/ci.yml.node scripts/action-pins.mjs --workflows .github/workflows— ok: 74 pinned references across 8 workflow files.node scripts/workflow-injection.mjs --workflows .github/workflows --allowlist .github/agent/workflow-injection-allowlist.json— ok: 8 workflow files, 2 allowlisted expressions.node scripts/gate-integrity.mjs --repo . --git-base 43210cf8… --git-head HEAD— ok: 9 gate files in 16 changed paths, all named in the summary.node scripts/ci-changes.mjs --repo . --git-base 43210cf8… --git-head HEAD—{"source":true,"workflows":true,"codeql":true,...}: the--repopath reads the head diff.CI round 1 on this branch, and the fix
The first run (
43708337449, tip8961a34) failed inchangeswithCannot find module '/home/runner/work/fledge/fledge/base/scripts/gate-integrity.mjs': the base branch is still43210cf8, which predates the file, so the pull request that introduces the summary had no base copy to run. That run'spr-lintalso failed on the body's## Ran/## Not runheadings, which is fixed above.715f35dmakes the step test the base path —[ -f base/scripts/gate-integrity.mjs ]— and write::notice::gate-integrity is not on the base branch yet; this change introduces itwhen it is absent. The guard reads the base tree, so a diff cannot decide whether the summary runs; once the file is onmain, every later pull request gets the summary.test/gateBaseTree.test.tspins that the guard namesbase/, anddocs/autonomous-dev.mdsays why it exists. Both branches were simulated locally with the exact run block: without the file the job exits 0 after the notice; with it, the summary is written (9 gate files in 16 paths). At715f35devery check was green exceptreview-gate, whose only finding was that a T3 tip needs a PASS — the PASS this PR is waiting for.The rerun on
715f35d(changes, run43708741449) is green: the base classifier printed{"docsOnly":false,"source":true,"workflows":true,"codeql":true,...}from the head diff, and the step emitted##[notice]gate-integrity is not on the base branch yet; this change introduces it.The negative demonstration, in a live run
The issue review asked for a throwaway pull request that stubs
scripts/ci-ok.mjstoprocess.exit(0)and touchessrc/, whose log names the base copy and still fails. It exists: #837, head3afe696118b181841bdb2146ef90a39f73b8811b, labelledprobe, body says not for merge. It is a mechanism probe, it is not part of this change, and only the owner closes it.scripts/ci-ok.mjstoprocess.exit(0)(head blob4d235cd3639ffb0e077260488f61ac45c9c9eb11) and addssrc/probe-base-tree.tswith a deliberate type error.43710167901,ci-okjob132800364004at that tip, verbatim from the log:scripts/ci-ok.mjs from the base tree: blob 78095383d7f01adab8078dacdb001e9b529131ba— the base copy, not the head blob.required job did not succeed: typecheck (failure),… test (failure),… coverage (failure),… build (failure),… docker (failure), thenci-ok: 5 required job(s) did not pass. The gate is red over a branch whose own copy of the gate says pass.PROBE:line anywhere in the log: the branch'sprocess.exit(0)never executed.5deaffa3(run43709764503,ci-okjob132799107783) prints the same base blob line and ends withci-ok: every required job passed (19 required).Both halves are live runs, not local simulation: one where the base evaluator passes, one where it fails a branch that edited it.
Not run:
probeuntil the owner removes it.actionlintlocally: no binary on this box. The pinnedraven-actions/actionlintstep runs it in CI, and this PR does not change the lint step's inputs.Risk and rollback
Tier T3 (
risk-gatebasis.github/workflows/ci.yml); a reviewer PASS at the tip is required.Rollback is a revert of this branch (
8961a34,715f35d,5deaffa3): the base checkouts, the--repo/--workflows/--allowlistarguments, the blob-sha log line,scripts/gate-integrity.mjs, its lib and its tests go, and every job returns to executing the head copy. No data, schema, config or route is involved.Docs decision
docs/autonomous-dev.mdis updated in this PR (the per-job table, the blob-sha log line, the fail-closed rule, and the residualpull_requestworkflow-YAML shape).Changelog-not-needed: this changes how CI executes its own checks; no user-visible behaviour, route or API changes.
maintainer_can_modify: true