Skip to content

ci: judge a pull request from the base branch tree - #836

Merged
HugoXK merged 3 commits into
mainfrom
dev1/640-gate-integrity
Nov 15, 2026
Merged

HugoXK merged 3 commits into
mainfrom
dev1/640-gate-integrity

Conversation

@HugoXK

@HugoXK HugoXK commented Nov 15, 2026 •

Copy link
Copy Markdown
Owner

Written-by: HugoXK

Fixes #640

Tip: 5deaffa3b061eaf9f28726dab637f15eacfc68f5, tree 82d00ee864ac4ed317f51964246206c2be9a7f35, base (this pull request's main) 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-lint and the CodeQL result gate all ran node scripts/... from the head tree, so a T3 diff that touches .github/** or scripts/** could edit the code that judges it. review-gate and pr-lint already checked the base ref out on purpose; these did not.

What changed

  • .github/workflows/ci.yml — the changes, risk, action-pins, workflow-lint, codeql-required and ci-ok jobs check the base ref out into base/ (github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.repository.default_branch) and run node base/scripts/<gate>.mjs. The head checkout stays, because it holds the data under test.
  • .github/workflows/codeql.yml — the result gate runs base/scripts/codeql-gate.mjs with --baseline base/.github/agent/codeql-baseline.json, so the baseline is the base copy too.
  • scripts/ci-changes.mjs and scripts/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.mjs and scripts/workflow-injection.mjs — the workflow directory is an argument (--workflows .github/workflows), and the injection allowlist is an explicit path pointing at base/.github/agent/workflow-injection-allowlist.json, so a pull request cannot allowlist its own expression.
  • scripts/gate-integrity.mjs (new) and scripts/lib/gate-integrity.mjs (new) — the changes job writes a gate-integrity summary 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/ or scripts/ is listed.
  • ci-ok prints 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_request event GitHub itself fetches the workflow YAML from the pull request, so a job's own run: lines are the head's text by construction. Moving the aggregate onto a workflow_run workflow (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 no base/scripts/... call and no path: base checkout.
  • the CodeQL result gate and its baseline come from the base branch — codeql-gate.mjs and 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 calls node scripts/ci-changes.mjs.
  • the classify step delegates the diff and keeps the every-gate fallback for other events — asserts node base/scripts/ci-changes.mjs and the --all branch that now also runs gate-integrity.
  • the workflow runs the script and no longer accepts a skipped job inline — asserts node base/scripts/ci-ok.mjs.
  • codeql-required is in ci-ok needs... — asserts node 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.ts as a file — scripts/lib/gate-integrity.mjs does 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 — the changes job does not test [ -f base/scripts/gate-integrity.mjs ] at the base.

The two behavioural tests in test/gateBaseTree.test.ts pass at the base by design, because they build their own base/ and head/ trees: one stubs head/scripts/ci-ok.mjs to process.exit(0) and shows the head copy exits 0 over a failing lint while the base copy exits 1 naming lint; the other rewrites head/scripts/lib/ciChanges.mjs to claim docsOnly and shows the base classifier still reads the head diff as source: true. They are the mechanism, not the regression.

Ran:

  • npm ci — 87 packages, exit 0.
  • npm run check at 5deaffa3b061eaf9f28726dab637f15eacfc68f5 — exit 0, 2171/2171 tests, lint clean, tsc --noEmit clean, build clean.
  • the red run above at 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 --repo path reads the head diff.

CI round 1 on this branch, and the fix

The first run (43708337449, tip 8961a34) failed in changes with Cannot find module '/home/runner/work/fledge/fledge/base/scripts/gate-integrity.mjs': the base branch is still 43210cf8, which predates the file, so the pull request that introduces the summary had no base copy to run. That run's pr-lint also failed on the body's ## Ran/## Not run headings, which is fixed above.

715f35d makes 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 it 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 that the guard names base/, and docs/autonomous-dev.md says 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). At 715f35d every check was green except review-gate, whose only finding was that a T3 tip needs a PASS — the PASS this PR is waiting for.

The rerun on 715f35d (changes, run 43708741449) 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.mjs to process.exit(0) and touches src/, whose log names the base copy and still fails. It exists: #837, head 3afe696118b181841bdb2146ef90a39f73b8811b, labelled probe, body says not for merge. It is a mechanism probe, it is not part of this change, and only the owner closes it.

  • The probe branch rewrites scripts/ci-ok.mjs to process.exit(0) (head blob 4d235cd3639ffb0e077260488f61ac45c9c9eb11) and adds src/probe-base-tree.ts with a deliberate type error.
  • Run 43710167901, ci-ok job 132800364004 at 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), then ci-ok: 5 required job(s) did not pass. The gate is red over a branch whose own copy of the gate says pass.
    • No PROBE: line anywhere in the log: the branch's process.exit(0) never executed.
  • The ordinary run on this pull request at 5deaffa3 (run 43709764503, ci-ok job 132799107783) prints the same base blob line and ends with ci-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:

  • Merging or closing probe: the gate ignores the branch copy of ci-ok (not for merge) #837 is not mine and not part of this PR; the probe stays open and labelled probe until the owner removes it.
  • actionlint locally: no binary on this box. The pinned raven-actions/actionlint step runs it in CI, and this PR does not change the lint step's inputs.
  • Docker smoke, live CodeQL analysis, live DB/SMTP: untouched by this diff.

Risk and rollback

Tier T3 (risk-gate basis .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/--allowlist arguments, 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.md is updated in this PR (the per-job table, the blob-sha log line, the fail-closed rule, and the residual pull_request workflow-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

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
HugoXK and others added 2 commits November 15, 2026 11:50
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 HugoXK-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 second actions/checkout with ref: github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.repository.default_branch and path: base, and each executes node base/scripts/<evaluator>.mjs (:113, :485, :539, :568, :688, :797). .github/workflows/codeql.yml:52-59,:66 does the same for codeql-gate.mjs, with the baseline read from base/.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-40 and scripts/risk-gate.mjs:56-62 (--repo), scripts/action-pins.mjs:10,17 and scripts/workflow-injection.mjs:8,18-19 (--workflows), with the injection allowlist read from base/ — a pull request can no longer allowlist its own expression.
  • Red first is genuine. At base 43210cf8 with 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.ts gives 90 tests, 76 pass, 14 fail, exit 1: the six "evaluates from the base branch tree" tests, the CodeQL baseline test, the classifier test, the ci-ok invocation test, the missing scripts/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-105 stubs the head scripts/ci-ok.mjs to process.exit(0) and shows it exits 0 over a failing lint while the base copy exits 1 naming lint; :107-150 rewrites the head scripts/ciChanges.mjs to claim docsOnly and shows the base classifier still reads the head diff as source: true.
  • The changes guard reads the base tree: .github/workflows/ci.yml:93-99 tests [ -f base/scripts/gate-integrity.mjs ], and test/gateBaseTree.test.ts:65-75 pins 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, stubbed scripts/ci-ok.mjs plus a deliberate src/ type error) is red in run 43710167901 with scripts/ci-ok.mjs from the base tree: blob 78095383… and no PROBE: line, while this PR's own run at the tip prints the base blob and ci-ok: every required job passed (19 required). I did not touch #837.

Non-blocking, for the follow-up slice:

  1. docs/autonomous-dev.md:346-348 says "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, and coverage is in ci-ok's needs at :765), :261 and :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.
  2. The residual you document at docs/autonomous-dev.md:382-386 is the one that still matters: on a pull_request event the workflow YAML is the head's copy, so a diff can still rewrite a run: line and skip base/. The protection there is the T3 PASS plus the gate-integrity summary naming the gate files, not this checkout split. I accept that scoping because it is stated and because ci-ok at the tip still requires a reviewer PASS, but nothing later should describe #640 as fully closed.

Ran:

  • npm run check at 5deaffa3 — pass, 2171/2171 tests, oxlint, tsc --noEmit and build clean
  • 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 at base 43210cf8 — 90 tests, 76 pass, 14 fail (the red half)
  • node scripts/gate-integrity.mjs --repo . --git-base 43210cf8aad25880f01cbbfb75e0dd5d8e84835d --git-head HEAD at the tip — ok, 9 gate files in 16 changed paths
  • node scripts/ci-changes.mjs --repo . --git-base 43210cf8aad25880f01cbbfb75e0dd5d8e84835d --git-head HEAD at 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/workflows at the tip — ok, 74 pinned references across 8 files
  • node scripts/workflow-injection.mjs --workflows .github/workflows --allowlist .github/agent/workflow-injection-allowlist.json at the tip — ok, 8 files, 2 allowlisted expressions
  • node scripts/pr-lint.mjs --pr 836 — ok (#836, T3, 16 files)
    Not run: actionlint locally (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.

@HugoXK-bot HugoXK-bot added the review:approved Issue reviewed and approved by the Fledge Platform Reviewer label Nov 15, 2026
@HugoXK
HugoXK merged commit 4c14825 into main Nov 15, 2026
33 of 34 checks passed
@HugoXK
HugoXK deleted the dev1/640-gate-integrity branch November 15, 2026 12:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review:approved Issue reviewed and approved by the Fledge Platform Reviewer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ci: execute gate decisions from the base branch, not the pull request head

2 participants