@@ -1355,6 +1355,35 @@ jobs:
13551355 - name : Shard attestation gate
13561356 run : pnpm check:shard-attestation
13571357
1358+ # Aggregator roster gate (#10490). Three required contexts are aggregate
1359+ # jobs standing in for a set of real jobs — `Test Core` and `Dogfood
1360+ # Regression Gate` in ci.yml, `TypeScript Type Check` in this file — and
1361+ # each decides its verdict from what it lists in `needs:`. Nothing held
1362+ # those rosters to anything. Add a lane to a workflow, forget the
1363+ # `needs:` line, and the lane's check-run is ADVISORY (only the aggregate
1364+ # NAME is in the ruleset's required set), so the aggregate goes green
1365+ # without ever looking at it: a job that appears in the checks list,
1366+ # appears required, and blocks nothing. Neither existing gate covers it —
1367+ # `check:required-contexts` pins the aggregate's NAME and job id and says
1368+ # nothing about `needs:`, and `check:shard-attestation` covers only the
1369+ # `--leg`-to-`needs:` direction for ci.yml's two.
1370+ # Each aggregate now DECLARES its membership in job-level
1371+ # `OS_AGGREGATOR_MEMBERS` / `OS_AGGREGATOR_NON_MEMBERS` (triage's ruling
1372+ # on the card: an explicit per-aggregator declaration, local to the
1373+ # workflow file, NOT a repo-wide job-id naming convention), and this gate
1374+ # asserts declaration == `needs:` in BOTH directions. Every unreadable
1375+ # state is a refusal rather than a quiet pass — no aggregator found, a
1376+ # workflow that will not parse, or a roster that resolves to zero members
1377+ # all exit 1 naming what could not be read (#4690).
1378+ # Invoked as `node` rather than through a `pnpm check:*` alias: that
1379+ # alias belongs in root package.json, which is declared territory of the
1380+ # @changesets/cli v3 migration lane (#9465) while it runs. Reads two YAML
1381+ # files; sub-second.
1382+ - name : Aggregator roster gate
1383+ run : |
1384+ node scripts/check-aggregator-roster.mjs --self-test
1385+ node scripts/check-aggregator-roster.mjs
1386+
13581387 # Required-context name pin (#6865). A GitHub required status check is
13591388 # matched BY CHECK-RUN NAME, and a job's check-run name is its `name:`
13601389 # value — so eight job `name:` literals across this file and ci.yml are
@@ -3104,6 +3133,26 @@ jobs:
31043133 - typecheck-workspace
31053134 - typecheck-debt
31063135 - typecheck-consumers
3136+ # ── ROSTER DECLARATION (#10490) ───────────────────────────────────────────
3137+ # The lanes this required aggregate stands in for, named rather than
3138+ # counted. It replaces the hand-maintained `EXPECTED_LANES = 4` that used to
3139+ # live in the verdict below: a number covers one aggregate, goes stale the
3140+ # moment a lane moves, and cannot say WHICH lane went missing.
3141+ #
3142+ # Two things read this list, which is why it is real YAML and not a comment:
3143+ # - `node scripts/check-aggregator-roster.mjs` (statically, in the lint
3144+ # job) asserts it equals this job's `needs:` EXACTLY, in both
3145+ # directions. A fifth lane added to this workflow and forgotten here
3146+ # would otherwise ride green as an ADVISORY check-run behind the
3147+ # required "TypeScript Type Check" context — a job that appears in the
3148+ # checks list, appears required, and blocks nothing;
3149+ # - the verdict step below (at run time) checks the same equality against
3150+ # `toJSON(needs)`, so a drift is red in CI even before the lint job
3151+ # reaches it.
3152+ # This aggregate has no non-member inputs: every job it lists in `needs:` is
3153+ # a lane whose verdict it carries.
3154+ env :
3155+ OS_AGGREGATOR_MEMBERS : typecheck-source-gates typecheck-workspace typecheck-debt typecheck-consumers
31073156 # `always()` is load-bearing and must not become `success()` or the implicit
31083157 # bare condition: with those, a failed lane SKIPS this job, and a skipped
31093158 # required context is not a red — it is an absence, which reads as green in
@@ -3120,10 +3169,12 @@ jobs:
31203169 # Fail-closed, and deliberately NOT a hand-written list of the four lane
31213170 # names. The verdict iterates `toJSON(needs)`, so every job wired into
31223171 # `needs:` above is enforced automatically and none can be forgotten in
3123- # the logic here. The lane COUNT is asserted separately as the tripwire
3124- # for the other half of that drift: a fifth lane added to the workflow but
3125- # never wired into `needs:` would otherwise ride green, unwatched, behind
3126- # a required context — the dormant-gate shape this repo keeps paying for.
3172+ # the logic here. The other half of that drift — a fifth lane added to the
3173+ # workflow but never wired into `needs:`, which would ride green and
3174+ # unwatched behind a required context — is caught by comparing `needs:`
3175+ # against the job's declared ROSTER (#10490). That replaces the hand-
3176+ # maintained `EXPECTED_LANES` count this step used to carry: a count goes
3177+ # stale the moment a lane moves and cannot say which lane went missing.
31273178 # Anything that is not exactly `success` fails, `skipped` and `cancelled`
31283179 # included: a lane that never ran has not proved anything.
31293180 - name : Verify every type-check lane succeeded
@@ -3135,15 +3186,27 @@ jobs:
31353186 cat > "$RUNNER_TEMP/verify-lanes.mjs" <<'NODE'
31363187 import { readFileSync } from 'node:fs';
31373188
3138- // Every lane this aggregator is expected to carry. Adding a lane to
3139- // `needs:` without bumping this is red on purpose, and so is the
3140- // reverse -- the number and the roster have to be changed together.
3141- const EXPECTED_LANES = 4;
3189+ // The roster this aggregator declares, read from the job-level
3190+ // `OS_AGGREGATOR_MEMBERS` above rather than re-typed here -- one
3191+ // declaration, two readers (this step, and the static
3192+ // scripts/check-aggregator-roster.mjs in the lint job).
3193+ const declared = (process.env.OS_AGGREGATOR_MEMBERS ?? '').trim().split(/\s+/).filter(Boolean);
3194+ const declaredSet = new Set(declared);
31423195
31433196 const needs = JSON.parse(readFileSync(`${process.env.RUNNER_TEMP}/needs.json`, 'utf8'));
31443197 const ids = Object.keys(needs).sort();
31453198 let bad = 0;
31463199
3200+ // An empty declaration would make every comparison below trivially
3201+ // true. Refuse rather than report a green over an unread roster.
3202+ if (declared.length === 0) {
3203+ console.log(
3204+ '::error::this job declares no OS_AGGREGATOR_MEMBERS roster, so there is nothing to hold `needs:` to -- ' +
3205+ 'refusing to report a pass (#4690).',
3206+ );
3207+ bad++;
3208+ }
3209+
31473210 for (const id of ids) {
31483211 const result = needs[id]?.result ?? '(no result reported)';
31493212 if (result === 'success') {
@@ -3154,11 +3217,18 @@ jobs:
31543217 bad++;
31553218 }
31563219
3157- if (ids.length !== EXPECTED_LANES) {
3220+ for (const id of declared.filter((d) => !ids.includes(d))) {
3221+ console.log(
3222+ `::error::declared lane \`${id}\` is missing from this job's \`needs:\`, so its verdict is NOT aggregated -- ` +
3223+ `it publishes an advisory check-run and rides green behind a required context.`,
3224+ );
3225+ bad++;
3226+ }
3227+
3228+ for (const id of ids.filter((i) => !declaredSet.has(i))) {
31583229 console.log(
3159- `::error::this gate aggregates ${ids.length} lane(s) but expects ${EXPECTED_LANES}: ${ids.join(', ')}. ` +
3160- `A lane was added or removed -- update EXPECTED_LANES here AND make sure every lane is listed in ` +
3161- `this job's \`needs:\`, or the unlisted one rides green behind a required context.`,
3230+ `::error::job \`${id}\` is in this job's \`needs:\` but not in the declared roster -- add it to ` +
3231+ `OS_AGGREGATOR_MEMBERS, or drop it from \`needs:\`.`,
31623232 );
31633233 bad++;
31643234 }
@@ -3167,6 +3237,6 @@ jobs:
31673237 console.log(`check-typecheck-lanes: FAILED (${bad} problem(s) above).`);
31683238 process.exit(1);
31693239 }
3170- console.log(`check-typecheck-lanes: OK (${ids.length}/${EXPECTED_LANES} lanes succeeded).`);
3240+ console.log(`check-typecheck-lanes: OK (${ids.length} declared lane(s) succeeded: ${ids.join(', ')} ).`);
31713241 NODE
31723242 node "$RUNNER_TEMP/verify-lanes.mjs"
0 commit comments