Skip to content

Commit b2a7447

Browse files
claude[bot]claude
andauthored
ci(gates): pin each required aggregate's needs: roster to an explicit member declaration (#10698)
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. Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt Co-authored-by: Claude <noreply@anthropic.com>
1 parent 78818ec commit b2a7447

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
@@ -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

Comments
 (0)