ci(smart-ci): accept a merge ref regenerated against the live protected base tip - #2506
ci(smart-ci): accept a merge ref regenerated against the live protected base tip#2506Chris0Jeky wants to merge 5 commits into
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Review gate (Codex credits exhausted, SC-9): one fresh-context reviewer with a trust-boundary lens confirmed the relaxation is one-sided — the head must still match exactly on every path (mismatchReason only yields 'base mismatch' when headSha === expectedHead), the live base tip is read from origin refs/heads/<base.ref> with the same token plumbing and cannot be moved by a fork PR, the merge/tree pair stays atomic, every non-accepting path still fails closed into requirePullRequestMergeBinding -> errorPlan -> red, and the trust-mismatch-to-note demotion is fenced behind plannerError which is an unconditional red in both modes. Verdict SHIP. Round 2 (test/doc-only, no re-review owed): observeBaseTip argv/token guard test + CLI wiring test, 'protected' wording corrected, literal newlines restored to escapes. MEDIUM on a machine-readable mergeBaseSha for the CI-03 verifier tracked separately (issue linked above). R4 proof = this PR's hosted Smart CI / Planner Self-Test + ci-required at the final head. |
…e newline escapes
|
Round 2 pushed as
Verification: |
Re-measured against live GitHub on 2026-09-04: - SC-4 clock: a09d986 did not leave a clean planner. Five same-shape shadow false reds landed 2026-09-04 (PR #2485 twice, #2496, #2515, #2500) from the CONTROL_BASE/merge-ref race, so the clock restarts when PR #2506 lands. #2506 becomes the first open blocker in the clause-5 chain, ahead of #2327. - Human-gate table: add the missing SC-9 and SC-10 rows from OUTSTANDING_TASKS section J, with the five SC-10 PRs measured open and #2522 CONFLICTING. - Clause-4 risks: #2425 (PR #2447) and #2399 (PR #2454) are closed; the open flake pair is #2489 and #2378, and the clause-4 row count follows. - Decision labels: #1936 is closed and #2004 no longer carries decision; the open decision-labelled milestone issues are #2324 and #1772. - Label split: 12 dogfooding / 17 ci / 22 other = 51, with the ci sub-breakdown, the ordinary-backlog list and the gated/un-gated arithmetic following it.
…2506 review-record gap in SC-10
Root cause
Not the hypothesised two-step merge/tree read —
observeMergeRefalready reads all four SHAs in asingle
git rev-parse(scripts/ci/smart-ci/resolve-merge-ref.mjs:82-98), so merge SHA and treeSHA can never come from different fetches.
The real failure is a base mismatch.
.github/workflows/smart-ci-shadow.ymlpinsCONTROL_BASE: ${{ github.sha }}— the base branch tip at event dispatch — and the resolver demandedthe merge ref's first parent equal it exactly. GitHub regenerates
refs/pull/N/mergeagainstwhatever the base branch points at now, so any push to
mainbetween dispatch and the resolver'sfetch makes the first parent the newer tip.
resolveMergeRefthen burns all three attempts on thesame permanent mismatch (main does not move back), fails closed, and — because the step is
continue-on-error: true— leaves nomerge-sha.txt/merge-tree-sha.txt. The Plan step omits--merge-sha/--merge-tree-sha, andrequirePullRequestMergeBinding(plan.mjs:55-61, from the #2401fix) throws the receipt's
planner-errortext. Sibling PRs in the same minutes passed simply becausetheir resolver window did not straddle a
mainpush.Confirmed in the Plan job log of run 33831258567 (PR #2485):
Same shape in runs 33833016055 (PR #2485) and 33832960392 (PR #2496).
The second receipt line,
trust-mismatch — plan says T3, the event re-derives T1, is purely asymptom:
errorPlanpinstrust: 'T3'by construction (lib/plan.mjs:362) andevaluateGatere-derives the real class from the event and reports the difference as a failure
(
lib/plan.mjs:473-474). It is not an independent defect.Fix
resolve-merge-ref.mjs— newobserveBaseTip()reads the base branch's live tip(
git fetch --depth=1 origin refs/heads/<baseRef>+rev-parse).resolveMergeRefaccepts aresolveBaseTipcallback: when the only discrepancy is the first parent, the observation isaccepted if and only if that parent is the live protected base tip. The event head must still
match exactly — a head mismatch (
base and head mismatch) never consults the base tip. Bothaccepted first parents are protected-branch heads, so nothing untrusted enters the binding.
Anything else, an unreadable tip, or a head mismatch stays fail-closed with no output files.
merge-ref-moved — the base advanced from <sha> to the live protected tip <sha> …and, via the new--note-out, is threaded into the plan receiptas a planner note, so the receipt says what happened rather than nothing.
smart-ci-shadow.yml— passes--base-ref "$BASE_REF"and--note-out, and forwards thenote to
plan.mjs --note.lib/plan.mjs— whenplan.plannerErroris set, the trust comparison becomes a note(
error-plan trust is pinned to T3; the event would classify T1) instead of atrust-mismatchfailure. The run is already red on
planner-error; the second line only misdescribed it.trust-mismatchremains a hard failure for real plans.requirePullRequestMergeBindingand its ordering (#2401 / PR #2440) are untouched.Refs #2327
Verification
node --test scripts/ci/smart-ci/*.test.mjs— 96 tests, 96 pass, 0 fail (was 91 before thisbranch; 5 new).
resolve-merge-ref.test.mjs:a merge ref regenerated against the live protected base tip resolves as merge-ref-moved(reproduces the failing sequence and proves the new behaviour),plus three fail-closed guards — a first parent that is not the live tip, a head mismatch that
never consults the base tip, and an unreadable base tip.
plan.test.mjs: an error plan's pinned trust is a note, while a real plan with a tamperedtrust class still fails
trust-mismatch.node scripts/check-docs-governance.mjs— passed.node --check scripts/ci/smart-ci/resolve-merge-ref.mjs— passed.Not verified locally: the hosted path.
observeBaseTip's actualgit fetch/rev-parseagainstGitHub, the workflow YAML as executed by Actions, and the end-to-end behaviour under a real base
push are proven only by this PR's own hosted
Smart CI / Planner Self-Testandci-requiredruns —pending at the time of writing. Policy fixtures untouched.
Risks
protected base tip). Both are heads of the protected base branch; the untrusted head parent is
still matched exactly. If the base ref were unprotected this would be weaker — Taskdeck's
mainis protected.
git fetchin the Plan job, only on the base-mismatch path.trust-mismatchunder an error plan is scoped toplan.plannerErrorbeing set; everysuch plan is already red on
planner-error, so no run can turn green through this change.