Uh oh!
There was an error while loading. Please reload this page.
ci(gates): pin each required aggregate's needs: roster to an explicit member declaration - #10698
Conversation
… member declaration Three required contexts are aggregator jobs standing in for a set of real jobs -- Test Core and Dogfood Regression Gate in ci.yml, TypeScript Type Check in lint.yml -- and each decides its verdict from what it lists in needs:. Nothing held those rosters to anything, so a lane added to a workflow and forgotten in needs: publishes an advisory check-run and rides green behind the required aggregate. Each aggregate now declares its membership in job-level OS_AGGREGATOR_MEMBERS / OS_AGGREGATOR_NON_MEMBERS, and scripts/check-aggregator-roster.mjs asserts declaration == needs: in both directions, wired into lint.yml as a direct node step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
PM review — verified against the diff, not the report. Approving. You answered my open question with a measurement, which is what I asked forI left the door open that So the same mould fits all three, and it closes a direction the attestation gate structurally Verified myself on the diff
The design decisions that make this hold up
⭐ Two things reported rather than smoothed overThe void-mutation self-catch. A5/A9 are the two additive cases, and they first ran with the
And the two families the derivation did not name — On #10703Filing it was the right call and I have verified its timeline independently: Two declared narrowings ( Arming once the remaining checks clear. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Both label-writing jobs in pr-automation.yml reached
`PUT /issues/{n}/labels` through a third-party action. A whole-set PUT is a
read-modify-write across a network round trip, so it destroys any label that
lands in between. Measured on PR #10698: a seat's additive `skip-changeset`
POST landed at 09:05:29Z and the size labeler's PUT erased it at 09:05:30Z,
which turns a PR that publishes nothing into a false changeset-check red.
Replaces both actions with scripts/pr-labels.mjs, which issues POST and
targeted DELETE only -- neither verb carries a label the writer does not name,
so neither can destroy a concurrent writer's label at any interleaving. The
plan builders are pure and the self-test asserts no plan can emit the
destructive verb; it also replays the #10698 interleaving directly.
Refs #10703
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
Closes#10490
The gap
Three required contexts are aggregator jobs standing in for a set of real jobs, each deciding its verdict from what it lists in
needs:—test-gate/Test Coreanddogfood-gate/Dogfood Regression Gatein ci.yml,typecheck/TypeScript Type Checkin lint.yml.Nothing held those rosters to anything. Add a lane to a workflow, forget the
needs:line, and the lane's check-run is advisory — only the aggregate NAME is in the ruleset's required set — so the aggregate goes green without ever looking at it. A job that appears in the checks list, appears required, and blocks nothing.Neither existing gate covers it:
check:required-contextspins each aggregate'sname:and job id and says nothing aboutneeds:.check:shard-attestationcovers ci.yml's two from one side only:scanWorkflowasserts every--legappears inneeds:, and that every attesting job is counted by exactly one gate. The reverse — a job wired into a gate'sneeds:that no--legcounts — was unchecked. Measured below: it stays green over exactly that.typecheckaggregate carried a hand-maintainedEXPECTED_LANES = 4. Correct today; a count covers one of three aggregates, goes stale the moment a lane moves, and cannot say which lane went missing.The shape
Per triage's ruling on the card's open question (2026-08-21): an explicit member declaration per aggregator, asserted equal to
needs:in both directions — not a repo-wide job-id naming convention across ci.yml/lint.yml. The declaration is local to the workflow file, in real YAML, as job-levelenv::OS_AGGREGATOR_NON_MEMBERSexists becauseneeds:is not only a roster: both ci.yml gates listfilterto apply the #4928 skipped-only-when-filter-succeeded guard. Without somewhere to say so, the honestneeds:would be unrepresentable and the gate would be satisfied by widening the roster — the failure it exists to catch. The self-test pins that laundering red: relabellingfiltera member reddens on the--legcross-check.scripts/check-aggregator-roster.mjs(new; wired into lint.yml as a directnodestep, no rootpackage.jsonalias — #9465 fence) asserts, per aggregator:needs:— the card's failure;needs:entry is declared, as a member or as a non-member input;needs:entry is a job that exists in that workflow;--legtokens, they equal the declared members — so the new declaration cannot become a second source of truth for whatcheck:shard-attestationcounts.Discovery is by declaration, so a new aggregator that adds the keys is covered without editing the script.
REQUIRED_AGGREGATORSis the floor under that: the three jobs carrying a branch-protection-required context must each be found and declared, so deleting a declaration to make a red go away is itself red.The
typecheckverdict step now reads the same declaration at run time instead ofEXPECTED_LANES: one declaration, two readers.Do ci.yml's two sharded aggregates need this, or is
check:shard-attestationenough?Measured, not reasoned. Added a real lane job to ci.yml, wired it into
test-gate'sneeds:, gave it no--leg— the card's failure mode in the sharded idiom — and ran both gates on the same tree. Mutation confirmed on disk first (test-newcomer:job block 0 to 1; widenedneeds:line 0 to 1 occurrences):So no: the explicit roster is not redundant for the sharded pair. It closes the direction the attestation gate structurally does not look at.
Every unreadable state is a refusal, not a quiet pass
A gate that reads a roster can pass while reading nothing: no aggregator found, a workflow that will not parse, or a roster resolving to zero members all make the comparison empty, and an empty comparison has no violations in it. Each exits 1 naming what could not be read (#4690). The baseline OK line states what it read, per aggregator — a bare
OKfrom a roster gate is indistinguishable from one that resolved none:Verification
All at
4134cbcb3d. Every exit code captured before any pipe; each verdict below is the line the tool printed, not a$?this PR wrote.A. Nine on-disk positive controls — each mutates a real workflow, proves the mutation landed by counting the injected text (additive cases) or the removed text (subtractive cases) before and after, runs the gate, restores from the commit and re-confirms
git status --porcelainempty. 9/9 as predicted:typecheckmember dropped fromneeds:typecheck-consumerstest-gatemember dropped fromneeds:testdogfood-gatemember dropped fromneeds:dogfood-verifytest-gateneeds:names a deleted jobtest-ghosttypecheckneeds:names a deleted jobtypecheck-ghostrefusing to report a passdeclares an EMPTY OS_AGGREGATOR_MEMBERScould not be checked at alldoes not parse as YAMLA5 and A9 are the additive cases. Their first run counted the unchanged anchor and read as a void mutation; the anchors were corrected to the injected text and both then moved 0 to 1. Recorded because a void mutation that reads as a pass is the failure this discipline exists for.
B. Six ablations of the shipped gate, each blinding one assertion, with the number of self-test failures predicted before the run. 6/6 exact; every leg restored byte-identical and re-verified green afterwards:
needs:)needs:names a missing job)needs:entry)--legcross-checkC. Five controls on the in-workflow
typecheckverdict step, extracted from lint.yml exactly as the runner materialises the heredoc and driven with synthetictoJSON(needs)payloads (that step is a required context's verdict, so a behaviour change to it gets its own controls). 5/5: baseline green naming its lanes; a lane atfailurered; a declared lane absent fromneeds:red; an undeclaredneeds:entry red; an empty declaration refused rather than passed.D. Gate union (
node scripts/pm/dispatch-gates.mjs, no paths, re-derived at4134cbcb3d) — all exit 0:check:nul-bytes·check:entry-guard·check:parse-guard·check:required-contexts·check:shard-attestation·check:workflow-status-functions·check:node-version·check:type-check-coverage·check:filter-alias-parity·check:cross-package-test-inputs·check:pm-dispatch-gates· the new gate and its self-test · eslint on the new file.check:entry-guardandcheck:nul-byteswere not in the derived list and were added by reasoning about the diff (a new exportingscripts/**file; any edit at all):check:entry-guard: 124 scripts/ file(s) — every entry guard goes through invoked-as.mjs; 82 export bindings, 47 of them inert on import (35 known-unsafe). The new file carriesisEntrypoint(import.meta.url)from the start and is not on the SHRINK-ONLY ledger.Two declared narrowings:
pnpm lintwas run as eslint over the one new file rather than the whole repo, andcheck:type-check-debt(the--re-measurehalf, which builds ledgered packages and reads no workflow) was not run — its workflow-reading half,check:type-check-coverage, is green above. CI runs both in full.Notes
skip-changesetper AGENTS.md and precedent PR feat(devx): ratchet check:engine-double-contract's consumer-seam population #10502.scripts/check-required-contexts.mjswas read, not edited (Burn down the 35 scripts/ modules that run on import — the KNOWN_IMPORT_UNSAFE ledger from PR #10665 #10667's burn-down is in it), andcheck-shard-attestation.mjsis deliberately not imported — both are oncheck:entry-guard'sKNOWN_IMPORT_UNSAFEledger, so importing either would run its gate as a side effect of reading a constant. The--legregex and the required-aggregate list are therefore re-spelled locally, with that reason in the docblock.Generated by Claude Code