Skip to content

Commit 4134cbc

Browse files
committed
ci(gates): pin each required aggregate's needs: roster to an explicit 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
1 parent dd8172e commit 4134cbc

3 files changed

Lines changed: 711 additions & 13 deletions

File tree

.github/workflows/ci.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,21 @@ jobs:
696696
# filter-`skipped` (#4928) keep their existing meanings — see the script.
697697
name: Test Core
698698
needs: [test, filter]
699+
# ── ROSTER DECLARATION (#10490) ───────────────────────────────────────
700+
# What this required aggregate stands in for, stated rather than inferred.
701+
# `node scripts/check-aggregator-roster.mjs` asserts MEMBERS + NON_MEMBERS
702+
# equals `needs:` EXACTLY, in both directions, so a lane added to this
703+
# workflow and forgotten here cannot ride green behind "Test Core" as an
704+
# advisory check-run, and a `needs:` entry whose job was deleted cannot
705+
# keep being claimed. `filter` is a non-member on purpose: it is consulted
706+
# for the #4928 skipped-only-when-filter-succeeded guard, not aggregated —
707+
# and having somewhere to SAY that is what stops the roster from being
708+
# widened to launder a red. The members are cross-checked against the
709+
# `--leg` tokens below, so this declaration cannot drift into a second
710+
# source of truth for what check:shard-attestation counts.
711+
env:
712+
OS_AGGREGATOR_MEMBERS: test
713+
OS_AGGREGATOR_NON_MEMBERS: filter
699714
if: always()
700715
runs-on: ubuntu-latest
701716
timeout-minutes: 10
@@ -1280,6 +1295,14 @@ jobs:
12801295
# is why `abandoned` gets counting rather than a place in a word list.
12811296
name: Dogfood Regression Gate
12821297
needs: [dogfood, dogfood-verify, filter]
1298+
# ── ROSTER DECLARATION (#10490) ───────────────────────────────────────
1299+
# Both legs this context covers, named. See test-gate above for the full
1300+
# note; the same gate asserts this list equals `needs:` in both directions
1301+
# and equals the `--leg` tokens the verdict actually counts. `filter` is
1302+
# the #4928 input, not a lane.
1303+
env:
1304+
OS_AGGREGATOR_MEMBERS: dogfood dogfood-verify
1305+
OS_AGGREGATOR_NON_MEMBERS: filter
12831306
if: always()
12841307
runs-on: ubuntu-latest
12851308
timeout-minutes: 10

.github/workflows/lint.yml

Lines changed: 83 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)