Skip to content

ci(gates): pin each required aggregate's needs: roster to an explicit member declaration - #10698

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-10490-aggregator-roster
Aug 21, 2026
Merged

ci(gates): pin each required aggregate's needs: roster to an explicit member declaration#10698
os-zhuang merged 1 commit into
mainfrom
claude/issue-10490-aggregator-roster

Conversation

@claude

@claudeclaudeBot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

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 Core and dogfood-gate / Dogfood Regression Gate in ci.yml, typecheck / TypeScript Type Check in 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-contexts pins each aggregate's name: and job id and says nothing about needs:.
  • check:shard-attestation covers ci.yml's two from one side only: scanWorkflow asserts every --leg appears in needs:, and that every attesting job is counted by exactly one gate. The reverse — a job wired into a gate's needs: that no --leg counts — was unchecked. Measured below: it stays green over exactly that.
  • The typecheck aggregate carried a hand-maintained EXPECTED_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-level env::

env:
OS_AGGREGATOR_MEMBERS: test # lanes whose verdict this gate carriesOS_AGGREGATOR_NON_MEMBERS: filter # in needs: for another reason

OS_AGGREGATOR_NON_MEMBERS exists because needs: is not only a roster: both ci.yml gates list filter to apply the #4928 skipped-only-when-filter-succeeded guard. Without somewhere to say so, the honest needs: 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: relabelling filter a member reddens on the --leg cross-check.

scripts/check-aggregator-roster.mjs (new; wired into lint.yml as a direct node step, no root package.json alias — #9465 fence) asserts, per aggregator:

  1. every declared member is in needs: — the card's failure;
  2. every needs: entry is declared, as a member or as a non-member input;
  3. every needs: entry is a job that exists in that workflow;
  4. every declared id is a job that exists;
  5. where the gate counts --leg tokens, they equal the declared members — so the new declaration cannot become a second source of truth for what check:shard-attestation counts.

Discovery is by declaration, so a new aggregator that adds the keys is covered without editing the script. REQUIRED_AGGREGATORS is 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 typecheck verdict step now reads the same declaration at run time instead of EXPECTED_LANES: one declaration, two readers.

Do ci.yml's two sharded aggregates need this, or is check:shard-attestation enough?

Measured, not reasoned. Added a real lane job to ci.yml, wired it into test-gate's needs:, 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; widened needs: line 0 to 1 occurrences):

check-shard-attestation exit=0
check-shard-attestation: 2 aggregate gate(s) count 3 declared leg(s) across 3 attesting job(s).
check-aggregator-roster exit=1
ci.yml job 'test-gate' lists 'test-newcomer' in needs: but declares it neither in
OS_AGGREGATOR_MEMBERS nor in OS_AGGREGATOR_NON_MEMBERS -- add it to the roster it
belongs to, or drop it from needs:.

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 OK from a roster gate is indistinguishable from one that resolved none:

check-aggregator-roster: 3 aggregator(s) across 2 workflow(s); roster == needs: in both
directions, and all 3 required-context aggregate(s) declared.
ci.yml/test-gate: 1 member(s) [test] + 1 non-member input(s) [filter] == needs: [filter, test] (cross-checked against 1 --leg token(s))
ci.yml/dogfood-gate: 2 member(s) [dogfood, dogfood-verify] + 1 non-member input(s) [filter] == needs: [dogfood, dogfood-verify, filter] (cross-checked against 2 --leg token(s))
lint.yml/typecheck: 4 member(s) [typecheck-source-gates, typecheck-workspace, typecheck-debt, typecheck-consumers] == needs: [typecheck-consumers, typecheck-debt, typecheck-source-gates, typecheck-workspace]

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 --porcelain empty. 9/9 as predicted:

controlanchorgate
A1 typecheck member dropped from needs:1 to 0exit 1, names typecheck-consumers
A2 test-gate member dropped from needs:1 to 0exit 1, names test
A3 dogfood-gate member dropped from needs:1 to 0exit 1, names dogfood-verify
A4 test-gateneeds: names a deleted job1 to 0exit 1, names test-ghost
A5 typecheckneeds: names a deleted job0 to 1exit 1, names typecheck-ghost
A6 REFUSAL — declaration unfindable1 to 0exit 1, refusing to report a pass
A7 REFUSAL — roster resolves to zero members1 to 0exit 1, declares an EMPTY OS_AGGREGATOR_MEMBERS
A8 REFUSAL — aggregator job gone1 to 0exit 1, could not be checked at all
A9 REFUSAL — workflow will not parse0 to 1exit 1, does not parse as YAML

A5 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:

ablationpredictedobserved
B1 blind direction 1 (member missing from needs:)66
B2 blind the required-aggregator floor22
B3 blind the zero-member refusal11
B4 blind direction 3 (needs: names a missing job)33
B5 blind direction 2 (undeclared needs: entry)33
B6 blind the --leg cross-check22

C. Five controls on the in-workflow typecheck verdict step, extracted from lint.yml exactly as the runner materialises the heredoc and driven with synthetic toJSON(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 at failure red; a declared lane absent from needs: red; an undeclared needs: entry red; an empty declaration refused rather than passed.

D. Gate union (node scripts/pm/dispatch-gates.mjs, no paths, re-derived at 4134cbcb3d) — 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-guard and check:nul-bytes were not in the derived list and were added by reasoning about the diff (a new exporting scripts/** 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 carries isEntrypoint(import.meta.url) from the start and is not on the SHRINK-ONLY ledger.

Two declared narrowings: pnpm lint was run as eslint over the one new file rather than the whole repo, and check:type-check-debt (the --re-measure half, 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


Generated by Claude Code

… 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
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 21, 2026
@github-actionsgithub-actionsBot added size/l ci/cd and removed skip-changeset PR has no user-facing published change; bypasses the changeset gate labels Aug 21, 2026
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 21, 2026
@claude

claudeBot commented Aug 21, 2026

Copy link
Copy Markdown
ContributorAuthor

PM review — verified against the diff, not the report. Approving.

You answered my open question with a measurement, which is what I asked for

I left the door open that check:shard-attestation might make an explicit roster redundant
for ci.yml's two matrix-sharded aggregates, and said to scope to typecheck if so. You checked
instead of arguing: scanWorkflow asserts leg → needs: only, and nothing asserts
needs: → leg. Adding a real lane job wired into test-gate's needs: with no --leg left
check-shard-attestation at exit 0 printing its normal green while the new gate exited 1
naming test-newcomer.

So the same mould fits all three, and it closes a direction the attestation gate structurally
does not look at. That is a better answer than either option I offered.

Verified myself on the diff

  • EXPECTED_LANES is genuinely gone, not merely wrapped: the const EXPECTED_LANES = 4, the
    ids.length !== EXPECTED_LANES test, both its error strings and the OK (n/4 …) line are all
    deleted, replaced by a runtime read of process.env.OS_AGGREGATOR_MEMBERS. The card named that
    hand-maintained count as "the classic thing that goes stale"; removing it rather than gating
    around it is more than I asked for.
  • Root package.json is untouched — zero diff hunks against it. The Migrate the release toolchain to @changesets/cli v3 — one atomic PR carrying the bump, the pre-mode restructure, and the gates that model v2's semantics #9465 fence held, and the
    gate is wired as node scripts/check-aggregator-roster.mjs --self-test followed by the bare
    invocation, directly in lint.yml. Both legs run, so the self-test executes where
    check:platform-checklist's could not.
  • All three aggregators carry the declaration, with OS_AGGREGATOR_NON_MEMBERS: filter on the
    ci.yml pair for the #4928 guard entry.

The design decisions that make this hold up

  • Discovery is by declaration, with REQUIRED_AGGREGATORS as a floor. A new aggregator that
    adds the keys is covered without editing the script, and deleting a declaration is itself red.
    Without the floor, the "declare your members" mechanism would be opt-out by omission — which is
    the failure mode a declaration surface most naturally has.
  • The --leg cross-check is the guard I would have asked for on second reading. A new
    declaration of what a gate aggregates could quietly become a second source of truth
    contradicting what check:shard-attestation counts. Asserting the two agree stops that at the
    point it is introduced rather than the first time they drift.
  • Four refusals, not an empty allow-list: declaration unfindable ("refusing to report a
    pass"
    ), zero members ("declares an EMPTY OS_AGGREGATOR_MEMBERS"), aggregator job absent
    ("could not be checked at all"), workflow unparseable. This is the control I said was the
    single most important assertion in the PR, and it is present in all four shapes.
  • Nine on-disk positive controls against real workflows, restored with git status --porcelain
    empty; six ablations of the shipped gate with the failure count predicted first, 6/6 exact; five
    controls on the in-workflow verdict step, extracted exactly as the runner materialises the
    heredoc rather than re-typed.

⭐ Two things reported rather than smoothed over

The void-mutation self-catch. A5/A9 are the two additive cases, and they first ran with the
unchanged anchor as the counter — so they read as void mutations. You re-pointed the anchors at
the injected text and both moved 0→1. That is the rule applied to its own instrumentation:
counting the anchor that was supposed to disappear tells you nothing when the edit's job was to
add something. Worth naming because it is the one variant of the void-mutation trap that survives
the usual discipline.

dispatch-gates now discovers the new gate from the workflow, which you correctly call
independent evidence that the wiring is real — a gate can claim to be CI-wired and not be, which
is exactly #10682's situation one card ago.

And the two families the derivation did not name — check:entry-guard and check:nul-bytes
were run by reasoning about the diff. The new file carries isEntrypoint(import.meta.url) from
the start and is not on the SHRINK-ONLY ledger, which is what I asked for and which matters
more than usual with #10704 burning that ledger down right now.

On #10703

Filing it was the right call and I have verified its timeline independently: skip-changeset
labeled by claude[bot] at 09:05:29Z, unlabeled by github-actions[bot] at 09:05:30Z,
bundled with its size/l write, re-applied at 09:06:03Z. That is a measured loss after a
correct additive POST returned 200 — materially worse than the near miss I recorded in #10686,
and I have posted a correction there pointing at your card as the sharper of the two. Catching it
required the after-the-bots read-back; nothing else would have shown it.

Two declared narrowings (pnpm lint scoped to the new file; check:type-check-debt --re-measure
not run, with its workflow-reading half green) are stated with their reasoning rather than
skipped silently. Fine — CI runs both in full.

Arming once the remaining checks clear.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 21, 2026 09:14
@os-zhuang
os-zhuang enabled auto-merge August 21, 2026 09:14
@os-zhuang
os-zhuang added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit b2a7447Aug 21, 2026
37 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-10490-aggregator-roster branch August 21, 2026 09:34
os-zhuang added a commit that referenced this pull request Aug 21, 2026
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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cdsize/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-zhuang@claude