@@ -1394,6 +1394,35 @@ jobs:
13941394 - name : Shard attestation gate
13951395 run : pnpm check:shard-attestation
13961396
1397+ # Aggregator roster gate (#10490). Three required contexts are aggregate
1398+ # jobs standing in for a set of real jobs — `Test Core` and `Dogfood
1399+ # Regression Gate` in ci.yml, `TypeScript Type Check` in this file — and
1400+ # each decides its verdict from what it lists in `needs:`. Nothing held
1401+ # those rosters to anything. Add a lane to a workflow, forget the
1402+ # `needs:` line, and the lane's check-run is ADVISORY (only the aggregate
1403+ # NAME is in the ruleset's required set), so the aggregate goes green
1404+ # without ever looking at it: a job that appears in the checks list,
1405+ # appears required, and blocks nothing. Neither existing gate covers it —
1406+ # `check:required-contexts` pins the aggregate's NAME and job id and says
1407+ # nothing about `needs:`, and `check:shard-attestation` covers only the
1408+ # `--leg`-to-`needs:` direction for ci.yml's two.
1409+ # Each aggregate now DECLARES its membership in job-level
1410+ # `OS_AGGREGATOR_MEMBERS` / `OS_AGGREGATOR_NON_MEMBERS` (triage's ruling
1411+ # on the card: an explicit per-aggregator declaration, local to the
1412+ # workflow file, NOT a repo-wide job-id naming convention), and this gate
1413+ # asserts declaration == `needs:` in BOTH directions. Every unreadable
1414+ # state is a refusal rather than a quiet pass — no aggregator found, a
1415+ # workflow that will not parse, or a roster that resolves to zero members
1416+ # all exit 1 naming what could not be read (#4690).
1417+ # Invoked as `node` rather than through a `pnpm check:*` alias: that
1418+ # alias belongs in root package.json, which is declared territory of the
1419+ # @changesets/cli v3 migration lane (#9465) while it runs. Reads two YAML
1420+ # files; sub-second.
1421+ - name : Aggregator roster gate
1422+ run : |
1423+ node scripts/check-aggregator-roster.mjs --self-test
1424+ node scripts/check-aggregator-roster.mjs
1425+
13971426 # Required-context name pin (#6865). A GitHub required status check is
13981427 # matched BY CHECK-RUN NAME, and a job's check-run name is its `name:`
13991428 # value — so eight job `name:` literals across this file and ci.yml are
@@ -3143,6 +3172,26 @@ jobs:
31433172 - typecheck-workspace
31443173 - typecheck-debt
31453174 - typecheck-consumers
3175+ # ── ROSTER DECLARATION (#10490) ───────────────────────────────────────────
3176+ # The lanes this required aggregate stands in for, named rather than
3177+ # counted. It replaces the hand-maintained `EXPECTED_LANES = 4` that used to
3178+ # live in the verdict below: a number covers one aggregate, goes stale the
3179+ # moment a lane moves, and cannot say WHICH lane went missing.
3180+ #
3181+ # Two things read this list, which is why it is real YAML and not a comment:
3182+ # - `node scripts/check-aggregator-roster.mjs` (statically, in the lint
3183+ # job) asserts it equals this job's `needs:` EXACTLY, in both
3184+ # directions. A fifth lane added to this workflow and forgotten here
3185+ # would otherwise ride green as an ADVISORY check-run behind the
3186+ # required "TypeScript Type Check" context — a job that appears in the
3187+ # checks list, appears required, and blocks nothing;
3188+ # - the verdict step below (at run time) checks the same equality against
3189+ # `toJSON(needs)`, so a drift is red in CI even before the lint job
3190+ # reaches it.
3191+ # This aggregate has no non-member inputs: every job it lists in `needs:` is
3192+ # a lane whose verdict it carries.
3193+ env :
3194+ OS_AGGREGATOR_MEMBERS : typecheck-source-gates typecheck-workspace typecheck-debt typecheck-consumers
31463195 # `always()` is load-bearing and must not become `success()` or the implicit
31473196 # bare condition: with those, a failed lane SKIPS this job, and a skipped
31483197 # required context is not a red — it is an absence, which reads as green in
@@ -3159,10 +3208,12 @@ jobs:
31593208 # Fail-closed, and deliberately NOT a hand-written list of the four lane
31603209 # names. The verdict iterates `toJSON(needs)`, so every job wired into
31613210 # `needs:` above is enforced automatically and none can be forgotten in
3162- # the logic here. The lane COUNT is asserted separately as the tripwire
3163- # for the other half of that drift: a fifth lane added to the workflow but
3164- # never wired into `needs:` would otherwise ride green, unwatched, behind
3165- # a required context — the dormant-gate shape this repo keeps paying for.
3211+ # the logic here. The other half of that drift — a fifth lane added to the
3212+ # workflow but never wired into `needs:`, which would ride green and
3213+ # unwatched behind a required context — is caught by comparing `needs:`
3214+ # against the job's declared ROSTER (#10490). That replaces the hand-
3215+ # maintained `EXPECTED_LANES` count this step used to carry: a count goes
3216+ # stale the moment a lane moves and cannot say which lane went missing.
31663217 # Anything that is not exactly `success` fails, `skipped` and `cancelled`
31673218 # included: a lane that never ran has not proved anything.
31683219 - name : Verify every type-check lane succeeded
@@ -3174,15 +3225,27 @@ jobs:
31743225 cat > "$RUNNER_TEMP/verify-lanes.mjs" <<'NODE'
31753226 import { readFileSync } from 'node:fs';
31763227
3177- // Every lane this aggregator is expected to carry. Adding a lane to
3178- // `needs:` without bumping this is red on purpose, and so is the
3179- // reverse -- the number and the roster have to be changed together.
3180- const EXPECTED_LANES = 4;
3228+ // The roster this aggregator declares, read from the job-level
3229+ // `OS_AGGREGATOR_MEMBERS` above rather than re-typed here -- one
3230+ // declaration, two readers (this step, and the static
3231+ // scripts/check-aggregator-roster.mjs in the lint job).
3232+ const declared = (process.env.OS_AGGREGATOR_MEMBERS ?? '').trim().split(/\s+/).filter(Boolean);
3233+ const declaredSet = new Set(declared);
31813234
31823235 const needs = JSON.parse(readFileSync(`${process.env.RUNNER_TEMP}/needs.json`, 'utf8'));
31833236 const ids = Object.keys(needs).sort();
31843237 let bad = 0;
31853238
3239+ // An empty declaration would make every comparison below trivially
3240+ // true. Refuse rather than report a green over an unread roster.
3241+ if (declared.length === 0) {
3242+ console.log(
3243+ '::error::this job declares no OS_AGGREGATOR_MEMBERS roster, so there is nothing to hold `needs:` to -- ' +
3244+ 'refusing to report a pass (#4690).',
3245+ );
3246+ bad++;
3247+ }
3248+
31863249 for (const id of ids) {
31873250 const result = needs[id]?.result ?? '(no result reported)';
31883251 if (result === 'success') {
@@ -3193,11 +3256,18 @@ jobs:
31933256 bad++;
31943257 }
31953258
3196- if (ids.length !== EXPECTED_LANES) {
3259+ for (const id of declared.filter((d) => !ids.includes(d))) {
3260+ console.log(
3261+ `::error::declared lane \`${id}\` is missing from this job's \`needs:\`, so its verdict is NOT aggregated -- ` +
3262+ `it publishes an advisory check-run and rides green behind a required context.`,
3263+ );
3264+ bad++;
3265+ }
3266+
3267+ for (const id of ids.filter((i) => !declaredSet.has(i))) {
31973268 console.log(
3198- `::error::this gate aggregates ${ids.length} lane(s) but expects ${EXPECTED_LANES}: ${ids.join(', ')}. ` +
3199- `A lane was added or removed -- update EXPECTED_LANES here AND make sure every lane is listed in ` +
3200- `this job's \`needs:\`, or the unlisted one rides green behind a required context.`,
3269+ `::error::job \`${id}\` is in this job's \`needs:\` but not in the declared roster -- add it to ` +
3270+ `OS_AGGREGATOR_MEMBERS, or drop it from \`needs:\`.`,
32013271 );
32023272 bad++;
32033273 }
@@ -3206,6 +3276,6 @@ jobs:
32063276 console.log(`check-typecheck-lanes: FAILED (${bad} problem(s) above).`);
32073277 process.exit(1);
32083278 }
3209- console.log(`check-typecheck-lanes: OK (${ids.length}/${EXPECTED_LANES} lanes succeeded).`);
3279+ console.log(`check-typecheck-lanes: OK (${ids.length} declared lane(s) succeeded: ${ids.join(', ')} ).`);
32103280 NODE
32113281 node "$RUNNER_TEMP/verify-lanes.mjs"
0 commit comments