diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d45e9074561..836a1104403 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -157,6 +157,12 @@ concurrency: # the majority of red runs at no cost to a green one (whose wall clock is the # sum of its steps, and so is order-independent) — ⛔ so do not re-sort them # back among their topical neighbours as tidying. +# +# Since #22991 those gates run in eight parallel lanes behind the +# `Lint & Repo Gates` aggregate, and the rule holds PER LANE: each lane runs +# its gates sequentially and aborts at its first red, every step kept the +# relative order it had in the single job, and the two anchor gates still sit +# immediately after the six setup steps, at the top of `lint-repo-tooling`. # ───────────────────────────────────────────────────────────────────────────── jobs: @@ -173,46 +179,231 @@ jobs: # verbatim. # # It is not called `ESLint` any more because it never was only that: the - # `pnpm lint` step below is one of ~70 sequential gate steps, so a red here - # was routinely read as "a lint problem" when it was a repo gate — three - # mis-routed diagnoses on 2026-08-17 alone (#9258, PRs #9256/#9291). + # `pnpm lint` step (now in the `lint-eslint` lane) was one of ~70 sequential + # gate steps when this was written, so a red here was routinely read as "a + # lint problem" when it was a repo gate — three mis-routed diagnoses on + # 2026-08-17 alone (#9258, PRs #9256/#9291). + # + # ── AN AGGREGATE SINCE #22991 (ruling 6120820653, letter D) ────────────── + # This job used to run every gate itself: 193 serial steps on one runner, + # p50 17.8 / p90 35.9 / max 38.5 minutes over the 70 successful runs read + # for #22991 (2026-10-12T04:40Z → 08:45Z, `pull_request` and + # `merge_group`, job and step timestamps from the jobs API), and the job + # the merge queue waited on in 13 of 25 groups. It now runs NO gate. The + # gates run in the eight `Lint · *` lanes below, in parallel, and this job + # is their aggregate: the shape `TypeScript Type Check` and its + # `Type Check · *` lanes have in this file. The required context is this + # job's NAME, so the name is kept byte-identical (AGENTS.md's merge-queue + # paragraph: a required context is matched by check-run name). The lanes + # carry new names and are advisory on their own; what blocks is this + # aggregate's verdict over them. + # + # ⛔ NO GATE WAS DROPPED, WEAKENED OR GIVEN A NEW COMMAND. Every gate step of + # the single job runs in exactly one lane, on the same events, with the + # same `run:`, the same `if:`, in the same relative order and with its + # comment block. ONE exception, the ruling's: `pnpm check:pm-dispatch-gates` + # runs its fast tier on `pull_request` and `merge_group` and both tiers on + # `schedule` and `push` (argued in `scripts/pm/check-dispatch-gates.mjs`'s + # header). What each lane repeats is the six setup steps, the gate-family + # selector where the lane carries a scoped step, and the unmeasured-tail + # reporter pair. Every lane keeps the single job's full-history checkout: + # which of the ~190 gates read history was not re-derived for this split, + # and a gate that loses its history degrades to "not verified" rather than + # going red. + # + # The lanes are independent. The only inter-step reads in the single job + # were `steps.gate-families.outputs.*` (nine `if:` lines, one step `id:`), + # and no step built, installed or generated a file a later step read, so no + # lane can miss an artifact another lane would have written. Each lane + # still runs its gates sequentially and stops at its first red, so the GATE + # ORDER note above `jobs:` holds per lane. + # + # THE CUT, from the per-step timestamps of those 70 runs (a lane's wall + # rebuilt per run: that run's own setup steps plus the lane's steps as they + # ran there, skipped ones at zero): + # + # lane steps p50 max (minutes) + # lint-eslint 2 3.3 4.0 + # lint-repo-tooling 60 4.2 4.7 + # lint-docs-code 55 4.1 4.6 + # lint-ci-engine 58 3.9 4.4 + # lint-scoped-erasure 2 3.5 5.0 + # lint-scoped-slot-corpus 3 3.5 4.9 + # lint-scoped-tooling 3 1.1 4.4 + # lint-pm-dispatch-gates 1 1.1 5.1 fast tier, estimated + # + # The ruled budget is 5 minutes per lane at median, and a lane over budget + # SPLITS, it does not skip: that is why the nine path-scoped families sit + # in four lanes rather than one (the eight besides the PM self-test ran 9 + # minutes together). These are predictions; the real numbers are the PR's + # own CI run and every run after it. Not taken, also ruled: per-family + # path scoping for the always-on gates, and input caches. Both need a + # declaration of what a gate reads, and a wrong declaration skips a gate + # silently. name: Lint & Repo Gates + needs: + - lint-eslint + - lint-repo-tooling + - lint-docs-code + - lint-ci-engine + - lint-scoped-erasure + - lint-scoped-slot-corpus + - lint-scoped-tooling + - lint-pm-dispatch-gates + # ── ROSTER DECLARATION (#10490), the `typecheck` aggregate's discipline ── + # The lanes this required aggregate stands in for, named rather than + # counted. `node scripts/check-aggregator-roster.mjs` (statically, in + # `lint-ci-engine`) holds this list equal to `needs:` in both directions, + # and holds this job to declaring one at all, since it carries a required + # context; the verdict step below checks the same equality against + # `toJSON(needs)` at run time. A ninth lane added to this workflow and + # forgotten here would otherwise ride green as an ADVISORY check-run behind + # the required "Lint & Repo Gates" context. This aggregate has no + # non-member inputs: every job in `needs:` is a lane whose verdict it + # carries. + env: + OS_AGGREGATOR_MEMBERS: lint-eslint lint-repo-tooling lint-docs-code lint-ci-engine lint-scoped-erasure lint-scoped-slot-corpus lint-scoped-tooling lint-pm-dispatch-gates + # `always()` is load-bearing and must not become `success()` or the + # implicit bare condition: with those, a failed lane SKIPS this job, and a + # skipped required context is an absence, which reads as green in every + # checks list and never blocks the queue. The `typecheck` job carries the + # same contract and says so at length. + if: always() runs-on: ubuntu-latest - # ⭐ JOB WALL — measured, and the only wall this job has. No step in this - # workflow runs under `scripts/run-with-stall-guard.mjs` (if one ever does, - # `pnpm check:stall-guard-budget` holds its cap under this number), so a - # step that stops producing output — a build, an install, a network read - # that never answers — is ended by nothing else. Without the key GitHub's - # default is 360 minutes, and this is the longest REQUIRED check in every - # merge-queue group. The queue does not wait that long: ruleset 12119582 - # (`main`) treats a required check that has not reported within 60 minutes - # (`check_response_timeout_minutes`) as failed and removes the entry — with - # no red check, and no `Merge Queue Triage` comment either (that workflow - # fires on a `failure` conclusion only, and it watches `CI`, not this - # workflow) — while the hung job holds a runner for up to five more hours, - # still `in_progress`. With the wall, a hung run ends `cancelled`, carrying - # GitHub's failure annotation "The job has exceeded the maximum execution - # time of …", before the queue's own window closes. + # ⭐ JOB WALL — measured, and the rule for every job in this file. No step in + # this workflow runs under `scripts/run-with-stall-guard.mjs` (if one ever + # does, `pnpm check:stall-guard-budget` holds its cap under its job's + # number), so a step that stops producing output — a build, an install, a + # network read that never answers — is ended by nothing else. Without the + # key GitHub's default is 360 minutes, and this context is the longest + # REQUIRED check in every merge-queue group. The queue does not wait that + # long: ruleset 12119582 (`main`) treats a required check that has not + # reported within 60 minutes (`check_response_timeout_minutes`) as failed + # and removes the entry — with no red check, and no `Merge Queue Triage` + # comment either (that workflow fires on a `failure` conclusion only, and + # it watches `CI`, not this workflow) — while the hung job holds a runner + # for up to five more hours, still `in_progress`. With the wall, a hung run + # ends `cancelled`, carrying GitHub's failure annotation "The job has + # exceeded the maximum execution time of …", before the queue's own window + # closes. # - # Measured: p50 15.8 / p99 32.6 / max 32.7 minutes (n = 81); a docs-only - # change runs it in about 7–11, a code change in 25–33. The window, for - # every job in this file: the 100 most recent completed runs of this - # workflow, 2026-10-01T00:23Z → 03:24Z (96 runs read, 525 successful job - # rows), job `started_at` → `completed_at` from the jobs API. + # Measured, before #22991 split this job into lanes: p50 15.8 / p99 32.6 / + # max 32.7 minutes (n = 81); a docs-only change ran it in about 7–11, a + # code change in 25–33. The window, for every job in this file: the 100 + # most recent completed runs of this workflow, 2026-10-01T00:23Z → 03:24Z + # (96 runs read, 525 successful job rows), job `started_at` → + # `completed_at` from the jobs API. # # The rule, for every job in this file: about 2× the measured maximum, # rounded to a 5-minute grain, never below 10 minutes, never above the - # queue's 60-minute window. Here the window binds, not 2× (2 × 32.7 = - # 65.4), and the cap is placed BELOW it, at 55: this job's clock starts - # only once a runner picks it up, after the queue's has started, so the - # five minutes are room for that pickup, and 55 still leaves 22 minutes - # (1.7×) over the measured maximum. Above 60 the stretch between the queue - # removing an entry and this check turning `cancelled` only grows, so when - # the measured maximum nears the wall the remedy is a faster job (or the - # queue's own setting, which is the maintainer's) — never a higher number - # here. Re-measure over a fresh window before changing any value in this - # file. - timeout-minutes: 55 + # queue's 60-minute window. For the single job the window bound, not 2× + # (2 × 32.7 = 65.4), and the cap was placed BELOW it, at 55: a job's clock + # starts only once a runner picks it up, after the queue's has started, so + # the five minutes are room for that pickup. 55 stays the CAP every job in + # this file sits under, the lanes below included. Above 60 the stretch + # between the queue removing an entry and this check turning `cancelled` + # only grows, so when a measured maximum nears the wall the remedy is a + # faster job (or the queue's own setting, which is the maintainer's) — + # never a higher number here. Re-measure over a fresh window before + # changing any value in this file. + # + # THIS job's own wall, since #22991: 10, the rule's floor — it runs one + # sub-second verdict and no checkout, the `typecheck` aggregate's number. + # Its clock starts only after the last lane ends, so the queue's 60 minutes + # are spent by the slowest lane's wall plus this one; every lane sizes its + # own wall in its header. + timeout-minutes: 10 + permissions: + contents: read + + steps: + # Fail-closed, and deliberately NOT a hand-written list of the lane names: + # the verdict iterates `toJSON(needs)`, so every job wired into `needs:` + # above is enforced, and the other half of that drift — a lane added to + # the workflow but never wired into `needs:` — is caught by comparing + # `needs:` against the declared ROSTER. A copy of the `typecheck` + # aggregate's verdict, not a shared module: that one runs with no + # checkout, and so does this. Anything that is not exactly `success` + # fails, `skipped` and `cancelled` included: a lane that never ran has + # not proved anything. + - name: Verify every lint lane succeeded + env: + OS_NEEDS: ${{ toJSON(needs) }} + run: | + set -euo pipefail + printf '%s' "$OS_NEEDS" > "$RUNNER_TEMP/needs.json" + cat > "$RUNNER_TEMP/verify-lanes.mjs" <<'NODE' + import { readFileSync } from 'node:fs'; + + // The roster this aggregator declares, read from the job-level + // `OS_AGGREGATOR_MEMBERS` above rather than re-typed here -- one + // declaration, two readers (this step, and the static + // scripts/check-aggregator-roster.mjs in the lint-ci-engine lane). + const declared = (process.env.OS_AGGREGATOR_MEMBERS ?? '').trim().split(/\s+/).filter(Boolean); + const declaredSet = new Set(declared); + + const needs = JSON.parse(readFileSync(`${process.env.RUNNER_TEMP}/needs.json`, 'utf8')); + const ids = Object.keys(needs).sort(); + let bad = 0; + + // An empty declaration would make every comparison below trivially + // true. Refuse rather than report a green over an unread roster. + if (declared.length === 0) { + console.log( + '::error::this job declares no OS_AGGREGATOR_MEMBERS roster, so there is nothing to hold `needs:` to -- ' + + 'refusing to report a pass (#4690).', + ); + bad++; + } + + for (const id of ids) { + const result = needs[id]?.result ?? '(no result reported)'; + if (result === 'success') { + console.log(` ok ${id}`); + continue; + } + console.log(`::error::lint lane \`${id}\` concluded \`${result}\` -- expected \`success\`.`); + bad++; + } + + for (const id of declared.filter((d) => !ids.includes(d))) { + console.log( + `::error::declared lane \`${id}\` is missing from this job's \`needs:\`, so its verdict is NOT aggregated -- ` + + `it publishes an advisory check-run and rides green behind a required context.`, + ); + bad++; + } + + for (const id of ids.filter((i) => !declaredSet.has(i))) { + console.log( + `::error::job \`${id}\` is in this job's \`needs:\` but not in the declared roster -- add it to ` + + `OS_AGGREGATOR_MEMBERS, or drop it from \`needs:\`.`, + ); + bad++; + } + + if (bad > 0) { + console.log(`check-lint-lanes: FAILED (${bad} problem(s) above).`); + process.exit(1); + } + console.log(`check-lint-lanes: OK (${ids.length} declared lane(s) succeeded: ${ids.join(', ')}).`); + NODE + node "$RUNNER_TEMP/verify-lanes.mjs" + + lint-eslint: + # Lane 1 of 8 behind the required `Lint & Repo Gates` aggregate (#22991). + # The `lint` job above holds the contract, the cut and the roster: read it + # before moving a step between lanes. This lane: ESLint and the engine + # test-double contract gate, the two heaviest always-on gates, side by + # side. It also carries the unmeasured-tail reporter's full argument and + # the `actions: read` scope it needs, which every lane repeats. + name: Lint · ESLint + runs-on: ubuntu-latest + # JOB WALL = CHECKOUT BUDGET (20, on the checkout step) + BODY BUDGET, the + # scheme `typecheck-source-gates` argues for a full-history checkout. Body + # after the checkout, rebuilt per run from the 70 runs the `lint` job + # cites: max 3.3 minutes; 2 × 3.3 = 6.7, 10 on the floor. 20 + 10 = 30. + timeout-minutes: 30 permissions: contents: read # ⭐ Read-only, and it buys exactly one thing: the LAST step of this job @@ -222,31 +413,13 @@ jobs: # `scripts/report-unmeasured-gate-tail.mjs`. Nothing else in this job # reads it, and no step writes anything anywhere. actions: read - # ⭐ Added by #18224, for the issue-citation step far below: it resolves - # the numbers THIS change cites against the board. Read-only and one - # scope wide — no issue, comment, label or assignee is ever written. It - # is spelled even though this board is PUBLIC, because an explicit - # `permissions:` block sets every unnamed scope to `none`, and a BLOCKING - # gate whose transport depends on repository visibility is a gate that - # goes red on a settings change no file here can assert. - issues: read - # ⭐ AND `pull-requests`, which is NOT redundant with the row above — - # MEASURED, on run 35495222460 of `half-state-patrol.yml`, whose token - # carries `issues` and NOT this scope: its census reported 3,628 - # unresolvable citation sites where a full-scope read of the same tree - # reported 2,168, and the difference is 1,460 — EXACTLY the - # `resolves-as-pull-request` tally. `GET /repos/{owner}/{repo}/issues` - # answers with the pull requests omitted, so every citation naming a PR - # number reads as a number the board never had. On THIS blocking step - # that is a false RED on a correct citation, and the frontier probe - # (`?per_page=1&sort=created&direction=desc`) is read from the same - # response — so an understated frontier turns later numbers into - # `never-issued` too. ⛔ Do not drop this row as tidying. - pull-requests: read steps: - name: Checkout repository uses: actions/checkout@v7 + # CHECKOUT BUDGET: `typecheck-source-gates` sizes it; the `lint` job + # above says why every lane carries it. + timeout-minutes: 20 with: # The slot-lookup ratchet compares the baseline against its state at # the merge base with main — the only way to see a file being ADDED @@ -278,127 +451,6 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - # ── Gate-family selection (#16496) ─────────────────────────────────── - # After #16453 this job is the merge queue's critical path (median 1468 s - # over the ten merge-group runs measured for #16496; the PM dispatch-gates - # self-test alone 597 s), and none of its expensive steps read a - # merge group's file surface: a docs-only group paid the full battery. - # So on `merge_group` and `pull_request` the NINE scoped families below - # -- each step carrying `if: steps.gate-families.outputs. != 'skip'` - # -- run only when the changed paths touch the files that family reads. - # (A tenth, `migration_registry` (#19753), retired with its gate in - # #22554: the registry it compared is no longer committed.) - # `push` on main and the scheduled full run keep the whole battery: the - # script runs everything for any event it does not scope. - # - # #19498 added four of the nine (`entry_guard`, - # `declared_population_live`, `bare_root_worklist`, - # `self_test_workflow_commands`) and narrowed `pm_dispatch_gates`, on - # ruling #208 on #19491 (R4) and the maintainer's sentence quoted in the - # selector's header: the tooling's self-tests were 18.6 minutes of the - # 27.4-minute job a three-file `packages/spec` PR paid. ⛔ Product - # ratchets and censuses stay unconditional -- the tenancy and - # tenant-audit censuses, the engine gates and everything else here judge - # product code and are not in that ruling. - # - # ⭐ The invariant is FAIL-OPEN, and it holds at both layers. The script - # runs every family when the base cannot be resolved, the diff fails or - # is empty, a path is one it does not classify (a new top-level - # directory, an unlisted root file), or any change is a deletion, rename - # or type change; a family is skipped ONLY when every changed path is - # positively classified into a class that family's DECLARED read-set - # excludes. For the other four -- both ratchets, the corpus walk and the - # verify-lock self-test -- that read-set is what the family provably - # reads. For the five tooling self-tests it is the tool's OWN INPUTS, - # which is a weaker claim; it is argued where it is declared, in the - # selector's header. - # The `!= 'skip'` spelling means an ABSENT output -- the selector never - # ran, or wrote nothing -- also runs the step. Both halves are pinned by - # `scripts/ci/select-gate-families.selftest.sh` (`check:select-gate- - # families`, further down this job), which also reads THIS file and - # refuses any other `if:` spelling and any family the script does not - # decide. The per-family read-sets, and how each was measured, are in - # the script's header. - # - # The selection itself lives in scripts/ci/select-gate-families.sh: - # environment in, `$RUNNER_TEMP/gate-families.txt` + `$GITHUB_OUTPUT` - # out, and a ran/skipped table in the job summary (the seat reads it). - # This step only exports the event's fields, so the same script -- every - # branch of it -- runs locally and under its self-test. - - name: Select the gate families this run pays for - id: gate-families - env: - OS_GATE_EVENT_NAME: ${{ github.event_name }} - OS_GATE_PR_BASE_REF: ${{ github.event.pull_request.base.ref }} - OS_GATE_MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} - run: | - bash scripts/ci/select-gate-families.sh - - # #7484 docs anchors: `lychee.toml` sets `include_fragments = "none"`, so - # the `Check Documentation Links` job resolves a link's FILE and never its - # `#anchor` — measured with the pinned lychee 0.24.2 under the CI argv, a - # link to a heading that does not exist is reported `[200] OK`. So a - # cross-file anchor was a two-sided invariant with no owner: rename the - # heading, leave the inbound link, ship green. This resolves every internal - # `#fragment` against the destination page's real heading ids, computed - # with `github-slugger` — the same package fumadocs-core renders the site - # with, so the gate and the renderer cannot disagree about a slug. - # - # It lives in this job rather than in check-links.yml for two reasons: it - # needs a workspace install (that job is deliberately a checkout plus two - # dependency-free `node` checks), and it is REQUIRED where that lane is - # advisory. The four anchors that were already dead were fixed in the same - # PR, so this ships with no baseline and no allowlist. - # - # ── FOLDED IN, deliberately: the `file:line` anchor half ────────────── - # - # `content/docs/permissions/system-context.mdx` calls itself "the - # authority" for every behaviour keyed off `ExecutionContext.isSystem` - # and says it is "built by census over the whole repo, not by recall". - # Nothing held it to either claim, and it rotted at a measured rate: 101 - # of its 111 anchors pointed at a line that no longer held what the row - # named, 19 days after the census was written, with CI green throughout. - # - # ⭐ The load-bearing direction is CENSUS -> PAGE, not page -> code. At - # the commit that motivated the card, re-resolving EVERY anchor on the - # page would have passed while it was missing 32 sites and its headline - # was 29 too low: a gate that only checks what a page already says can - # never find what the page failed to say. Measured again here on the - # shipped gate -- with an unanchored read site injected, the resolution - # checks report 0 findings and the population check names the site. - # - # It is a second command in THIS step rather than a step of its own: this - # job has no paths filter, so the census runs on code-only PRs (the ones - # that ADD a read site), and folding costs no new check context, no new - # `check:*` manifest key, and no new required-status entry for one page. - # The `--self-test` invocation is what `check:self-test-wired` requires, - # and it is the only instrument on this gate's matching rules -- a clean - # tree cannot tell a working rule from a weakened one. - # - # Invoked as `node` rather than through a `pnpm check:*` alias: see the - # GATE INVOCATION IDIOM note at the top of this file. - - name: Docs anchors resolve to real headings - run: | - pnpm check:doc-anchors - node scripts/check-system-context-census.mjs --self-test - node scripts/check-system-context-census.mjs - - # #3723 ADR anchors: code an accepted ADR governs must keep naming it. - # That incident reversed three accepted ADRs with a patch-level changeset, - # and the mechanism was simply that the edited file never mentioned them — - # so the author could not have known. Presence check only; the invariant - # travels in the failure message (scripts/adr-anchors/, one file per anchor since #6957). - # - # Since #5992 the same script also audits the premise the anchors rest on: - # an ADR number names exactly ONE decision. Three numbers were each claimed - # by two unrelated records (0010/0019/0057) — grandfathered on an explicit - # shrink-only allowlist in the script, so a FOURTH collision goes red here. - # The `--self-test` in the pnpm script provokes that red path on synthetic - # directories every run, because a collision is rare enough that nobody - # would otherwise see the gate fail before it mattered. - - name: ADR anchors + number uniqueness (governed code names its decision) - run: pnpm check:adr-anchors - # No "migration registry matches its entry files" step since #22554: # `packages/spec/src/migrations/registry.ts` is git-ignored and generated # whole from `registry.ts.template` + `entries/` by `gen:migration-registry`, @@ -412,44 +464,205 @@ jobs: - name: ESLint run: pnpm lint - # Slot-lookup sweep ratchet (#4251). `pnpm lint` above bans erasing a - # service-lookup result to `any` across packages/, but the files still - # holding pre-existing sites are grandfathered by path — and an ignored - # file is ignored COMPLETELY, so new erasures added to one ride the old - # entry in silence. This re-measures those files with the grandfathering - # lifted and holds them to a per-file count, so the list can only shrink. - # - # Scoped (#16496): reads `packages/**/*.{ts,tsx,mts,cts}`, its baseline, - # `eslint.config.mjs` and the top-level `scripts/*.mjs` it imports; a - # group touching none of those skips it (selection step above). - - name: Slot-lookup ratchet - if: steps.gate-families.outputs.slot_lookup != 'skip' - run: pnpm check:slot-lookup + # Engine test-double contract gate (#4550, from #4434). A test double + # LOOSER than the implementation it replaces turns a green suite into no + # suite at all, silently, on exactly the paths a double was introduced + # for. #4434 is the worked example: DELETE /sharing/rules/:idOrName + # answered 500 for every rule and both address forms from the day it was + # written, while `deleteRule drops rule + all its grants` asserted success + # against a fake engine that accepted the one call shape ObjectQL.delete + # refuses. This holds every fake ObjectQL engine's `delete` to the real + # dispatch predicate — imported from @objectstack/objectql, not + # hand-mirrored, so it cannot drift — with the pre-existing fakes in a + # shrink-only, measured baseline. Static AST only, so it needs no build + # and belongs in this job. Runs its own --self-test first: the detector + # can be broken while every fake is fine, and a scan that quietly stops + # matching would report OK while reading nothing (#4868's family). + - name: Engine test-double contract gate + run: pnpm check:engine-double-contract - # Engine query-options erasure ratchet (#4918). The slot-lookup rule above - # protects the service LOOKUP; this one protects what you pass to the - # service you looked up. `IDataEngine.find/findOne/count/aggregate` declare - # their options as `EngineQueryOptions` & co., and for an internal caller - # `tsc` is the ONLY channel enforcing them — the protocol's ingress - # normalizer never sees a direct engine call, and the options schemas are - # not `.strict()`, so an unknown key is silently DROPPED. #4674 is the - # bill: two queries sorted by `direction` instead of `order`, both with a - # `limit`, so both returned the OLDEST rows — audit history that never - # showed recent changes, and a search that truncated away fresh records. - # #4720 restored those two sites and #4721 closed the external callers; - # this stops the shape regrowing internally. Same ratchet mechanism as - # slot-lookup for the non-test residual, plus one aggregate decrease-only - # number for test code (a test whose subject IS off-contract engine input - # must be able to build it). Runs its own --self-test first. + # ── NOT GATES. The two steps below judge nothing about the tree. ────── # - # Scoped (#16496): the same population and inputs as the slot-lookup - # ratchet above, with `scripts/query-options-erasure-baseline.json`. - - name: Engine query-options erasure ratchet - if: steps.gate-families.outputs.query_options_erasure != 'skip' - run: pnpm check:query-options-erasure + # ⭐ One red in this job is a LOWER BOUND on the number of problems in the + # tree, not a count — every gate behind the failing one never executed, + # and a gate that never executed produced no output, which is the same + # shape a passing gate has. The GATE ORDER note above `jobs:` already + # names this quantity ("the unmeasured tail") and is tuned to shrink it; + # until now nothing printed it. These steps print it. + # + # ⛔ The fix is NOT to make the skipped gates run: that would undo the + # adjudicated fail-fast order. ⛔ Nor is it `continue-on-error`, which + # trades a red for silence. The job's conclusion is untouched. + # + # Measured before this was written, on run 34468505490 of this very + # workflow (job 102842713265, failed at step #55): the jobs API and the web + # UI DO mark the un-run steps `skipped` — 112 of them there — but they + # never count them, and they use that same word for a step its own `if:` + # turned off, so the one surface that says it cannot tell the two apart. + # The check-run `output` was null on title, summary and text alike, and the + # job log — what an API or agent reader gets — said nothing at all. So what + # is added here is a machine-readable COUNT, on all three surfaces. + + # Offline, no token, no network: drives the reporter's judge() over + # recorded jobs-API shapes, and checks that the step below still hands + # itself its own name. Wired as a step because an unrun self-test is a + # phantom check. Sub-second. + # + # ⭐ `if: failure()` for the same reason the step below carries it, and it + # is the whole negative control: a green run of this job must be BYTE + # IDENTICAL to what it was before any of this existed. A self-test that + # ran unconditionally would print one line on every green run — one line + # is still new noise, and this change is not allowed to cost any. It is + # exercised on exactly the runs its subject is used on, which in this repo + # is not a rare event. ⛔ Do not "fix" this by dropping the condition. + # It fails LOUDLY: it is a separate step, so a broken reporter shows up as + # its own red step rather than as a missing paragraph. + - name: Unmeasured-gate-tail reporter self-test + if: failure() + run: node scripts/report-unmeasured-gate-tail.mjs --self-test + # Runs BECAUSE something failed. On a green head `failure()` is false, so + # it is skipped and contributes zero bytes to the log, the summary and the + # annotations — measured, not assumed. It always exits 0: the job is + # already failed when it runs, and a non-zero exit here would only add a + # second annotation pointing at the reporter instead of at the gate. + - name: Report how many gates never ran + if: failure() + env: + GITHUB_TOKEN: ${{ github.token }} + # ⚠️ Must equal this step's own `name:` above. The reporter subtracts + # it so that it does not list ITSELF among the steps that never ran — + # measured in run 34584689309, where it did exactly that, because the + # jobs API had not yet marked it `in_progress`. Drift between the two + # lines costs one extra, visibly silly line in the report; it cannot + # go wrong quietly. + OS_TAIL_REPORT_STEP: Report how many gates never ran + run: node scripts/report-unmeasured-gate-tail.mjs + + lint-repo-tooling: + # Lane 2 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the + # cut). The always-on gates that ran from the top of the single job down + # to the two tenancy censuses: the docs and ADR anchors (still first, per + # the GATE ORDER note above `jobs:`), the `scripts/**` guards and + # shared-module self-tests, the PM tooling self-tests, the Claude hook + # guards, the docs authoring gates and both censuses. + name: Lint · repo & tooling gates + runs-on: ubuntu-latest + # JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`' + # scheme. Body after the checkout, rebuilt per run from the 70 runs the + # `lint` job cites: max 4.1 minutes; 2 × 4.1 = 8.1, 10 on the floor. + # 20 + 10 = 30. + timeout-minutes: 30 + permissions: + contents: read + # The unmeasured-tail reporter at the end of this lane: `lint-eslint` + # argues the scope. + actions: read + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + # CHECKOUT BUDGET: `typecheck-source-gates` sizes it; the `lint` job + # above says why every lane carries it. + timeout-minutes: 20 + with: + # The slot-lookup ratchet compares the baseline against its state at + # the merge base with main — the only way to see a file being ADDED + # to the grandfather list. A shallow clone has no merge base, and the + # check would degrade to "not verified" on every run. + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v6 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-v3- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # #7484 docs anchors: `lychee.toml` sets `include_fragments = "none"`, so + # the `Check Documentation Links` job resolves a link's FILE and never its + # `#anchor` — measured with the pinned lychee 0.24.2 under the CI argv, a + # link to a heading that does not exist is reported `[200] OK`. So a + # cross-file anchor was a two-sided invariant with no owner: rename the + # heading, leave the inbound link, ship green. This resolves every internal + # `#fragment` against the destination page's real heading ids, computed + # with `github-slugger` — the same package fumadocs-core renders the site + # with, so the gate and the renderer cannot disagree about a slug. + # + # It lives in this job rather than in check-links.yml for two reasons: it + # needs a workspace install (that job is deliberately a checkout plus two + # dependency-free `node` checks), and it is REQUIRED where that lane is + # advisory. The four anchors that were already dead were fixed in the same + # PR, so this ships with no baseline and no allowlist. + # + # ── FOLDED IN, deliberately: the `file:line` anchor half ────────────── + # + # `content/docs/permissions/system-context.mdx` calls itself "the + # authority" for every behaviour keyed off `ExecutionContext.isSystem` + # and says it is "built by census over the whole repo, not by recall". + # Nothing held it to either claim, and it rotted at a measured rate: 101 + # of its 111 anchors pointed at a line that no longer held what the row + # named, 19 days after the census was written, with CI green throughout. + # + # ⭐ The load-bearing direction is CENSUS -> PAGE, not page -> code. At + # the commit that motivated the card, re-resolving EVERY anchor on the + # page would have passed while it was missing 32 sites and its headline + # was 29 too low: a gate that only checks what a page already says can + # never find what the page failed to say. Measured again here on the + # shipped gate -- with an unanchored read site injected, the resolution + # checks report 0 findings and the population check names the site. + # + # It is a second command in THIS step rather than a step of its own: this + # job has no paths filter, so the census runs on code-only PRs (the ones + # that ADD a read site), and folding costs no new check context, no new + # `check:*` manifest key, and no new required-status entry for one page. + # The `--self-test` invocation is what `check:self-test-wired` requires, + # and it is the only instrument on this gate's matching rules -- a clean + # tree cannot tell a working rule from a weakened one. + # + # Invoked as `node` rather than through a `pnpm check:*` alias: see the + # GATE INVOCATION IDIOM note at the top of this file. + - name: Docs anchors resolve to real headings + run: | + pnpm check:doc-anchors + node scripts/check-system-context-census.mjs --self-test + node scripts/check-system-context-census.mjs + + # #3723 ADR anchors: code an accepted ADR governs must keep naming it. + # That incident reversed three accepted ADRs with a patch-level changeset, + # and the mechanism was simply that the edited file never mentioned them — + # so the author could not have known. Presence check only; the invariant + # travels in the failure message (scripts/adr-anchors/, one file per anchor since #6957). + # + # Since #5992 the same script also audits the premise the anchors rest on: + # an ADR number names exactly ONE decision. Three numbers were each claimed + # by two unrelated records (0010/0019/0057) — grandfathered on an explicit + # shrink-only allowlist in the script, so a FOURTH collision goes red here. + # The `--self-test` in the pnpm script provokes that red path on synthetic + # directories every run, because a collision is rare enough that nobody + # would otherwise see the gate fail before it mattered. + - name: ADR anchors + number uniqueness (governed code names its decision) + run: pnpm check:adr-anchors # @objectstack/verify stand-in erasure guard (#6399). The third and - # narrowest member of the two above. `checkReadCoercion` / + # narrowest member of the slot-lookup and query-options erasure ratchets + # (in the scoped lanes since #22991). `checkReadCoercion` / # `checkDateBucketParity` take their driver STRUCTURALLY # (`CoercibleDriver` / `BucketableDriver`) so an out-of-tree driver — # cloud's driver-turso in remote mode — runs the identical contract @@ -515,7 +728,9 @@ jobs: run: pnpm check:gitlink-declared # The first three of these are the shared modules the two `scripts/**` - # routing gates below DELEGATE their design arguments to (#10608). Both of + # routing gates DELEGATE their design arguments to (#10608) — the parse + # guard below, and the entry guard in `lint-scoped-slot-corpus` since + # #22991. Both of # those gates are # SPELLING gates by deliberate design — `check:entry-guard` enforces that # only `scripts/invoked-as.mjs` may read `process.argv[1]`, and @@ -565,7 +780,7 @@ jobs: # rest of that remainder from here; that is a separate card. # # What runs here is the self-tests, not the modules' callers: for the first - # three the callers are the two gate steps below, which already run on + # three the callers are the two routing gates named above, which already run on # every PR; for `import-prerequisite` the callers are the 45 importing # gates, whose ordinary runs take the branch where the dependency IS # present and therefore observe none of what the self-test pins. @@ -623,36 +838,6 @@ jobs: fi echo "scripts/ shared-module self-tests — all four ran and passed" - # Every `scripts/**` entry guard goes through ONE predicate (#10086). - # The hand-typed forms of "did node run me, or did someone import me?" - # had drifted into ELEVEN spellings across 33 files, and NINE were wrong - # in the same invisible direction: node resolves symlinks for the module - # graph but leaves `process.argv[1]` as the caller typed it, so a script - # reached through a symlink compared two different paths, answered false, - # and did NOTHING — exit 0, no output. The CI wrappers here hold the - # child's exit STATUS only, so an inert child read as a green gate; the - # governed-surface register (`scripts/pm/check-governed-merges.mjs`) was - # among the affected, where the inert run and its "NOT governed, ordinary - # queue landing applies" verdict are the SAME exit code. - # The sweep alone would not have held — nothing stopped a twelfth - # spelling. This gate is the part that closes the class: only - # `scripts/invoked-as.mjs` may read `process.argv[1]`, and that module's - # own self-test drives a real probe through a real symlink. Rationale and - # the rejected behavioural-sweep alternative: the gate script's header. - # Scans ~115 files, no spawns; ~0.2s of work, measured at 0.62 min as a - # step on run 35506407130. - # - # Scoped (#19498): its population is `scripts/**` — its own - # ROOT_DIR_WATCH_HINTS declaration, held against the root it really walks - # by its own self-test — so a group that changes no file under scripts/ - # (and no root configuration) skips it. The selection step at the top of - # this job decides that, `push` on main and the hourly run keep it - # unconditional, and the ruling that authorizes moving a tooling - # self-test off the PR path is quoted in the selector's header. - - name: scripts/ entry guards go through one predicate - if: steps.gate-families.outputs.entry_guard != 'skip' - run: pnpm check:entry-guard - # Every `scripts/**` TypeScript parse goes through ONE module (#10133 / # #10573), and this is the half that keeps it that way (#10574). # NONE of the three parser entry points throws on a source it cannot @@ -670,7 +855,8 @@ jobs: # The #10573 sweep converted 32 call sites across 15 gates; it could not # stop the sixteenth being typed, and within the hour a new gate landed # with two raw calls in it — caught by this step, which is the whole - # argument for having it. Same shape as `check:entry-guard` above. + # argument for having it. Same shape as `check:entry-guard` (in + # `lint-scoped-slot-corpus` since #22991). # Also prints the parses OUTSIDE `scripts/**` (#10575) that it does not # govern, so its green line is read as a claim about `scripts/` and not # about the repository. @@ -809,51 +995,6 @@ jobs: - name: shell scripts hold the bash 3.2 floor run: pnpm check:bash32-floor - # The comment-mask CORPUS sweep (#10640) — the other instrument for the - # module the two gates above delegate "is this span a comment, or code?" - # to. `js-comment-mask.mjs --self-test` pins the SHAPES someone wrote - # down; this parses every .{ts,tsx,mts,cts,js,mjs,cjs,jsx} file in the - # tree with @typescript-eslint/parser and diffs its comment ranges - # against the mask's, byte for byte. Neither subsumes the other, and that - # is measured in both directions on this tree rather than assumed: - # deleting the `{` counting inside `${...}` fails a pinned case and the - # sweep reads 0 disagreements over 4,741 files (the tree does not write - # that shape), while dropping `return` from REGEX_AFTER_KEYWORD passes all - # 23 pinned cases and the sweep names check-test-source-alias.mjs, where - # `return /(^|[^a-z])dist\//` is written today. - # The defect this would have caught shipped: 16 files disagreeing, 15 of - # them reading commented-out text as live code, 10,252 comment bytes in - # one file — and the instrument that found it lived in an agent's - # scratchpad, which is why re-deriving it was impossible until this file. - # WHY A HARD GATE ON EVERY PR, and not an on-demand flag: the sweep's - # verdict depends on the CORPUS as much as on the masker, so a PR that - # writes a shape the mask reads wrong turns ~20 source-scanning gates - # into liars on that file, on a PR that never touched the masker. That - # arrival is not detectable by any convention a human or an agent has to - # remember at the right moment — the failure mode this repo has already - # paid for at #4690 and again here. - # WHY A STEP AND NOT A JOB: a new job's `name:` is a required-context - # contract (scripts/check-required-contexts.mjs) that only a - # maintainer-present Settings change can enrol, and an unenrolled job is - # an advisory gate — #5617 verbatim. This job is already required. - # Invoked as `node` rather than `pnpm check:*`, same as the other - # `node scripts/…` steps in this lane: see the GATE INVOCATION IDIOM note - # at the top of this file. - # Runs its own comparator self-test first — 12 cases proving the - # comparison can still REPORT, because "0 files disagree" and "the - # comparison is broken" otherwise print the same line. - # Cost, measured on a contended 4-vCPU container: 4,741 files, 72 MB of - # source, 45–48 s, of which the parser is 45 s. The step prints CI's own - # number on every run. - # - # Scoped (#16496): the walk reads every `.ts .tsx .mts .cts .js .mjs - # .cjs .jsx` file in the tree, so a group that changes no file of those - # extensions (and no root configuration) skips it -- selection step at - # the top of this job. - - name: Comment mask agrees with a real parser over the whole corpus - if: steps.gate-families.outputs.comment_mask_corpus != 'skip' - run: node scripts/check-comment-mask-corpus.mjs - # Stack-collection enumerations vs the schema (#6242). `stack.zod.ts` # decides which collections a stack may declare; eight other enumerations # of that same set are hand-maintained (the map-format list, the @@ -970,86 +1111,9 @@ jobs: - name: PM label description cap run: pnpm check:pm-label-desc-cap - # PM dispatch-gates self-test (#8162). `scripts/pm/dispatch-gates.mjs` - # derives the "local gates for this card" line of every dispatch prompt, - # and carried a 61-case --self-test that NO job ran: it executed only when - # a human or an agent typed it. A break in the extraction functions landed - # green and surfaced later as a prompt naming the wrong gate families — - # output that reads as correct, produced by the one tool whose purpose is - # to stop gate lists being memory-shaped. Same family as the - # `Changeset-family gate self-tests` step above. Until #16496 this step - # was deliberately UNCONDITIONAL -- no `if:`, no label read, no paths - # filter -- on the argument that an exemption is what a self-test must - # not have, or the gap moves. That argument was about an exemption a - # PATH FILTER or a LABEL would grant: a paths: trigger or a label read - # skips the step on a card that never touched the paths someone once - # listed, with no reader of what the test actually reads. What runs - # here now is different in kind and was the maintainer's call (#16496, - # 「同意你的建议,你负责执行派发所有可行的优化」): the selection step at - # the top of this job classifies every changed path against a declared - # read-set for this step, and skips it when no changed path is in that - # set. Every doubt runs it, the self-test of the selector pins that, and - # `push` on main keeps it unconditional. - # - # ⚠️ Since #19498 that read-set is NARROWER than what the battery reads, - # and the gap is stated rather than papered over. The read-set is the - # tool's own inputs: every workflow and composite action, every gate - # source under `scripts/**` and a workspace package's own `scripts/`, - # every `package.json`, `.claude/**`, `skills/**`, `AGENTS.md`, - # `CLAUDE.md` and root configuration. The battery ALSO reads the CONTENT - # of every JS/TS and `.sh` file in the tree (the compound-anchor census - # of `function ...SelfTest...(` declarations and the exposed-scratch-dir - # sweep of every mkdtempSync/mkdirSync caller, with the nested - # `.gitignore` files it consults) and the tracked NAME set it sweeps — - # which is why, until #19498, any added file anywhere ran it. On PR - # #19314 this step alone was 11.8 minutes of a 27.4-minute job for a - # three-file `packages/spec` diff, and ruling #208 on #19491 (R4) took - # those whole-tree reads off the PR path on the maintainer's sentence, - # quoted in the selector's header. ⛔ A defect in that wider set can now - # first appear on `main`; the push-on-main and hourly full runs are what - # bound it, and widening the skip further is again a maintainer call. - # - # The gate runs the SELF-TEST only. The live derivation - # (`node scripts/pm/dispatch-gates.mjs `) answers a question about a - # card's file surface, is print-only, and exits 0 on any completed run — - # there is no verdict in it for CI to hold, only a slow read of the whole - # workflow tree. Why the gate is its own file rather than pointing - # `check:pm-dispatch-gates` straight at the tool is measured and argued in - # `scripts/pm/check-dispatch-gates.mjs`'s header (the tool's self-test - # fixtures are path strings, and they become watch hints that fabricate - # MATCHED leads across most of the tree). - # - # ⛔ This paragraph deliberately carries NO cost figure (#12831). The line - # that used to close it — "Reads two files and walks packages/; ~0.3s" — - # described the 61-case self-test named at the top, and the part that grew - # since is exactly the part it denied: the live-tree cases run the same - # discovery the tool does (every workflow file, and then the source of - # every gate that discovery finds), build temporary git repositories and - # drive `changedPathsFromGit` against them, and sweep the tracked corpus. - # That is not "two files", and it is not a walk of packages/. The estimate - # went with the description rather than being refreshed: a replacement has - # to be read off a real CI run of THIS step, because a reading taken in an - # agent container is a reading of a contended box and not of a runner - # (#12528). A cost nobody re-measures ends up endorsing a decision it has - # no standing to endorse, which is what this one had become. No live - # counts stand here either, for the same reason — they rot the same way, - # and it is the SHAPE of the work, not its size, that the argument needs. - # - # ⛔ Cost was never what decided this step's conditionality, and the - # measured medians that motivated #16496 (597 s here, over ten - # merge-group runs) live in that card and its PR, not in this note. - # Losing the estimate is NOT an argument for a label read or a paths - # filter, and the read-set-scoped `if:` below is not one of those: it - # was a maintainer call (the first paragraph names it), and widening the - # skip -- any change to the classes the selector treats as inert for - # this family -- is again a maintainer call, taken in - # scripts/ci/select-gate-families.sh under its self-test. - - name: PM dispatch-gates self-test - if: steps.gate-families.outputs.pm_dispatch_gates != 'skip' - run: pnpm check:pm-dispatch-gates - # Every ROOT_DIR_WATCH_HINTS declaration stays READABLE BY A TEXT SCANNER - # (#12762). The step above proves the extractor still works; this one + # (#12762). The PM dispatch-gates self-test (its own lane since #22991) + # proves the extractor still works; this one # proves it still has something to extract. `extractWatchHints` reads # source text, so a declaration rewritten from a literal into a mapped # expression over the gate's population constant contributes NO hint at @@ -1067,34 +1131,6 @@ jobs: - name: ROOT_DIR_WATCH_HINTS declarations are literals run: pnpm check:watch-hint-literal - # The other half of the same failure (#13519). The step above holds that a - # declaration is still READABLE BY A TEXT SCANNER; this one holds that - # what it reads still names something. A family whose declared literals - # all reach zero tracked files prints as an ORDINARY SILENCE — byte for - # byte the output of a gate that declared nothing — so the derivation and - # the gate disagree about whether a population was declared and neither - # says so. Measured on the tree this landed against: two families, one of - # them a gate that sweeps every workflow file and appeared on no card that - # edited one. The rule is per-FAMILY, not per-literal: a gate may spell a - # slug or a sentinel path beside a live declaration, and only a whole - # declaration that reaches nothing is refused. No allowlist and no gate - # names — the stronger rule ("a gate that enumerates a directory must - # declare one") was implemented, measured at 86 findings over 114 - # enumerating gate files, and refused as an allowlist with a verdict - # attached. Reads the derivation once over the tracked corpus; ~5s of - # work, measured at 0.40 min as a step on run 35506407130. - # - # Scoped (#19498): it imports `discoverFamilies` and `trackedFiles` from - # the dispatch derivation, so its inputs are the workflow tree, every - # gate source that discovery resolves, and the tracked NAME set — and - # only a name that DISAPPEARS can turn a live declaration dead, which is - # a structural change the selector already runs everything for. A group - # confined to product source, tests, docs or changesets skips it here and - # pays for it on `push` to main and on the hourly run. - - name: A declared gate population reaches the tree - if: steps.gate-families.outputs.declared_population_live != 'skip' - run: pnpm check:declared-population-live - # ADR-0087 D4's per-release correctness gate (#17080). The REAL run needs # two published tarballs and npm, so it lives in the release lane and # cannot run on a PR; what runs here is its `--self-test`, which is @@ -1107,42 +1143,12 @@ jobs: - name: Per-release spec-changes gate self-test run: pnpm check:release-spec-changes - # PM bare-root worklist self-test (#10840). The step above proves the - # dispatch derivation still WORKS; this one proves the recorded triage of - # the gates that derivation structurally cannot see is still true of the - # tree. A gate whose population is spelled as a bare single-segment word - # builds no watch hint at all — not a dead hint, not a silent verdict, - # nothing — so it is unnameable by any dispatch brief and leaves no residue - # saying so. The worklist enumerates that species and carries one recorded - # verdict per row; this step fails when a verdict outlives its row (the - # shrink) or when a new gate joins the species unjudged. - # - # ⛔ It runs the SELF-TEST only, never the sweep as a verdict: recognising - # this species needs a heuristic over constant NAMES, and #10705 refused to - # put one on the path that derives every PR's gate list. Nothing in - # `dispatch-gates.mjs` reads the worklist, and no verdict in it reaches a - # dispatch prompt. - # - # Reads the workflow tree and every gate source once; ~0.5s of work, - # measured at 0.32 min as a step on run 35506407130. - # - # ⚠️ This step WAS unconditional, on the reasoning that a self-test which - # can be skipped is the gap moving rather than closing. #19498 scopes it - # anyway, and the trade is explicit rather than reasoned away: it imports - # the same derivation as the two steps above, so its inputs are the - # workflow tree and the gate sources, and on a group touching neither the - # gap does move — to `push` on main and the hourly full run, which keep - # the whole battery. Gate weakening is a maintainer floor; the sentence - # that authorizes this one is quoted in the selector's header. - - name: PM bare-root worklist self-test - if: steps.gate-families.outputs.bare_root_worklist != 'skip' - run: node scripts/pm/bare-root-worklist.mjs --self-test - # Part-of/closing-keyword guard self-test (#8476). The guard itself is a # PR-scoped blocking check in its own workflow — it needs a pull request # body to judge, which this job does not have — so what runs HERE is its # self-test, which is the half with a verdict independent of any PR. - # Unconditional, and not on the two steps above's borrowed reason — they + # Unconditional, and not on the borrowed reason of the declared-population + # and bare-root steps (the scoped lanes since #22991) — they # are scoped since #19498 and this one is not, because the cost that # bought that ruling is not here: this step is a tenth of a second, and a # self-test that runs only when someone remembers is a check whose @@ -1894,103 +1900,6 @@ jobs: node scripts/check-self-test-wired.mjs --self-test node scripts/check-self-test-wired.mjs - # Self-test output vs. the runner's command parser (#11886). Sibling of - # the step above and sharing its population: that one asks whether CI RUNS - # each self-test, this one asks what each self-test PRINTS while it does. - # - # The defect it closes was live on every PR in this repo. A line of - # `scripts/pm/ci-failure.mjs`'s self-test prose named a workflow-command - # token literally, inside backticks, as documentation of what that tool - # anchors on. The runner does not know a token is being quoted: it parsed - # it and minted `annotation_level: failure` on a check run whose - # conclusion was SUCCESS. On `d63b01436` that artifact was the ONLY - # annotation the `Lint & Repo Gates` run carried. A failure-level - # annotation on a green required check is a claim that something failed, - # and a repo that mints one on every PR is teaching its readers to skim - # exactly the surface other gates report findings into. - # - # ⚠️ INVISIBLE TO EVERY LOCAL RUN, which is the whole reason it is a gate: - # the text is ordinary prose on disk and in a terminal, and only a runner - # transforms it. It was found by reading one job's annotations through the - # API, not by anything in this file. - # - # Two facts were measured on real runs and the gate's shape follows them: - # the legacy `##[...]` form is parsed ANYWHERE in a printed line (the - # measured token sat at column 18 and was still consumed), while the - # `::...::` form is parsed only at LINE START (two mid-sentence ones print - # on every PR from `check-prerelease-pin-watch` and mint nothing). So the - # first detector is unanchored and the second is anchored — flagging inert - # prose would be a false positive the next author would be right to delete. - # - # It RUNS the self-tests rather than reasoning about their source, because - # "is this token printed?" is only answerable by printing it. A static - # prefilter picks which ones to run — over-selecting freely, since the - # verdict never comes from it — which is what keeps this step at ~14 s - # instead of the ~4 min the whole population costs. - # - # Invoked as `node scripts/…` rather than through a `pnpm check:*` alias: - # see the GATE INVOCATION IDIOM note at the top of this file. Reads - # `scripts/` and `.github/workflows/` off disk and spawns the selected - # self-tests; no network. Measured at 0.48 min as a step on run - # 35506407130. - # - # Scoped (#19498): its declared population is - # `scripts/**/*.mjs`, `scripts/**/*.mts` and `scripts/**/*.sh`, and the - # rest of its read-set is the workflow tree and `.github/actions` it - # discovers the runnable self-tests from — so a group touching none of - # those skips it here and runs it on `push` to main and on the hourly - # full run. - - name: Self-test workflow-command gate - if: steps.gate-families.outputs.self_test_workflow_commands != 'skip' - run: | - node scripts/check-self-test-workflow-commands.mjs --self-test - node scripts/check-self-test-workflow-commands.mjs - - # Verify-lock entry-point self-test (#9661). `scripts/pm/os-verify-lock.sh` - # is the ONE way an agent takes the container's shared heavy-verify lock, - # and it is the enforcement of a rule that used to live only in prose: the - # acquisition budget os-dev.md declares is now unrepresentable above its - # cap, grants are ordered by a ticket file, and every run reports how long - # it held. Measured before it existed, in one container: five live waiters, - # three of them 6x over the declared cap, and the compliant ones were the - # ones not verifying — a convention whose violators win is not a convention. - # - # Nothing in CI *uses* the lock (GitHub runners are one job per machine), - # so this step exists to keep the mechanism honest rather than to serialise - # anything: 48 cases over the budget clamp, ticket liveness (dead pid, - # REUSED pid, over-age), fd hygiene, exit-code passthrough, and real - # multi-process contention — queue timeout, legacy free-hand coexistence in - # both directions, and three staggered waiters acquiring in arrival order. - # - # Since #10289 it also guards the BASH 3.2 FLOOR and the bounded-refusal - # rule, which is why the count moved: `/usr/bin/env bash` is 3.2.57 on - # macOS, and a bash 4+/5+ construct there does not fail loudly — it makes - # the acquisition deadline unreachable and turns the wait into an unbounded - # spin that prints no VERDICT at all. So the suite now scans the - # acquisition path for 4+/5+ constructs, runs a real acquisition with - # EPOCHSECONDS/EPOCHREALTIME unset and mapfile disabled, and asserts that - # an unusable host (no `flock`) and a stopped clock each REFUSE with a - # verdict and a non-zero exit rather than waiting forever. - # - # It runs entirely on a private lock under a temp dir; it never touches - # /tmp/os-heavy-verify.lock, so a runner and an agent container behave the - # same. No network; median 100 s over the ten merge-group runs measured - # for #16496. - # - # Scoped (#16496): the suite reads the script itself (`$SELF`) and a - # private temp dir, and its case (h) runs the real entry point from the - # repo root, which routes through filter_preflight -> - # scripts/pnpm-filter-targets.mjs --preflight and so reads the top-level - # scripts/*.mjs helpers that imports, pnpm-workspace.yaml and every - # workspace package.json. A group touching none of those skips it. That - # is not the exemption the self-tests above refuse (a label or a paths - # filter with no reader behind it): the selection step at the top of - # this job runs this step on every doubt, and `push` on main keeps it - # unconditional. - - name: Verify-lock entry-point self-test - if: steps.gate-families.outputs.verify_lock != 'skip' - run: bash scripts/pm/os-verify-lock.sh --self-test - # ci-failure self-test (#9898). Fifth member of the PM self-test family # above, and until now the odd one out: `scripts/pm/ci-failure.mjs` — the # one command from "a check is red" to "here is the failing assertion" — @@ -2353,40 +2262,121 @@ jobs: node scripts/check-platform-object-tenancy-census.mjs --self-test node scripts/check-platform-object-tenancy-census.mjs - # #12236 one

per doc page. `DocsTitle` renders the frontmatter `title` - # as the page's

unconditionally (apps/docs/app/[lang]/docs/[[...slug]]/ - # page.tsx), so a body-level `# ` heading compiles to a SECOND one inside - # `DocsBody`. Measured on the dev server at /docs/data-modeling/objects - # before the sweep: two

, both reading "Object Metadata". 195 of the 403 - # pages were in that state, 138 of them with the two

identical. - # - # It lives next to check:doc-anchors because the two share the corpus and - # the heading model — this gate imports `stripFrontmatter` and - # `flattenHeadingText` from that one rather than re-modelling either, which - # is what makes the delete-vs-demote remedy right on the nine pages whose - # two headings differ only by inline code markup. - # - # The gate strips FENCED CODE before looking for a heading, and that is the - # load-bearing half: a naive `^# ` scan reports 205 files, ten of which are - # shell/YAML comments inside working examples. A fence-blind rule does not - # over-report by ten, it instructs the fixer to corrupt ten snippets. - # - # Two subtrees are excluded, each with a named owner (#12249 generated - # reference pages, #12250 release notes) and each self-retiring: the gate - # FAILS with DEAD-EXCLUSION when an excluded tree becomes clean, so the - # carve-outs cannot outlive their reasons. A run that judges zero pages - # fails too (#4690). - - name: One `

` per docs page - run: pnpm check:docs-single-h1 + # ── NOT GATES: the unmeasured-tail reporter pair, per lane (#22991). The + # reporter finds its own job by runner name, so each lane counts its own + # tail. Argued in full at the end of `lint-eslint`. ────────────────────── + - name: Unmeasured-gate-tail reporter self-test + if: failure() + run: node scripts/report-unmeasured-gate-tail.mjs --self-test - # #9014 docs redirects: `apps/docs/redirects.mjs` is a 92-entry table of - # PERMANENT (308) redirects, and before this step nothing in the repo read - # it — `dispatch-gates.mjs apps/docs/redirects.mjs` named no family over 99 - # discovered ones, and `git grep -l 'redirects.mjs' -- scripts .github` had - # 0 hits. #8948 found three destinations aiming at pages that do not exist: - # live URLs answering a 308 into a 404, which browsers cache and crawlers - # treat as final. The gate resolves every destination the way Fumadocs - # routes it, requires a wildcard destination's target directory to exist, + # Runs BECAUSE something failed. On a green head `failure()` is false, so + # it is skipped and contributes zero bytes to the log, the summary and the + # annotations — measured, not assumed. It always exits 0: the job is + # already failed when it runs, and a non-zero exit here would only add a + # second annotation pointing at the reporter instead of at the gate. + - name: Report how many gates never ran + if: failure() + env: + GITHUB_TOKEN: ${{ github.token }} + # ⚠️ Must equal this step's own `name:` above. The reporter subtracts + # it so that it does not list ITSELF among the steps that never ran — + # measured in run 34584689309, where it did exactly that, because the + # jobs API had not yet marked it `in_progress`. Drift between the two + # lines costs one extra, visibly silly line in the report; it cannot + # go wrong quietly. + OS_TAIL_REPORT_STEP: Report how many gates never ran + run: node scripts/report-unmeasured-gate-tail.mjs + + lint-docs-code: + # Lane 3 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the + # cut). The always-on gates that ran from the docs page gates through the + # changeset-family self-tests: the docs and teaching-corpus ratchets, the + # symbol anchors, and the product-code guards (CLI, error codes, routes, + # logging, the objectui pin). + name: Lint · docs & code guards + runs-on: ubuntu-latest + # JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`' + # scheme. Body after the checkout, rebuilt per run from the 70 runs the + # `lint` job cites: max 4.0 minutes; 2 × 4.0 = 8.0, 10 on the floor. + # 20 + 10 = 30. + timeout-minutes: 30 + permissions: + contents: read + # The unmeasured-tail reporter at the end of this lane: `lint-eslint` + # argues the scope. + actions: read + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + # CHECKOUT BUDGET: `typecheck-source-gates` sizes it; the `lint` job + # above says why every lane carries it. + timeout-minutes: 20 + with: + # The slot-lookup ratchet compares the baseline against its state at + # the merge base with main — the only way to see a file being ADDED + # to the grandfather list. A shallow clone has no merge base, and the + # check would degrade to "not verified" on every run. + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v6 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-v3- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # #12236 one

per doc page. `DocsTitle` renders the frontmatter `title` + # as the page's

unconditionally (apps/docs/app/[lang]/docs/[[...slug]]/ + # page.tsx), so a body-level `# ` heading compiles to a SECOND one inside + # `DocsBody`. Measured on the dev server at /docs/data-modeling/objects + # before the sweep: two

, both reading "Object Metadata". 195 of the 403 + # pages were in that state, 138 of them with the two

identical. + # + # It lives next to check:doc-anchors because the two share the corpus and + # the heading model — this gate imports `stripFrontmatter` and + # `flattenHeadingText` from that one rather than re-modelling either, which + # is what makes the delete-vs-demote remedy right on the nine pages whose + # two headings differ only by inline code markup. + # + # The gate strips FENCED CODE before looking for a heading, and that is the + # load-bearing half: a naive `^# ` scan reports 205 files, ten of which are + # shell/YAML comments inside working examples. A fence-blind rule does not + # over-report by ten, it instructs the fixer to corrupt ten snippets. + # + # Two subtrees are excluded, each with a named owner (#12249 generated + # reference pages, #12250 release notes) and each self-retiring: the gate + # FAILS with DEAD-EXCLUSION when an excluded tree becomes clean, so the + # carve-outs cannot outlive their reasons. A run that judges zero pages + # fails too (#4690). + - name: One `

` per docs page + run: pnpm check:docs-single-h1 + + # #9014 docs redirects: `apps/docs/redirects.mjs` is a 92-entry table of + # PERMANENT (308) redirects, and before this step nothing in the repo read + # it — `dispatch-gates.mjs apps/docs/redirects.mjs` named no family over 99 + # discovered ones, and `git grep -l 'redirects.mjs' -- scripts .github` had + # 0 hits. #8948 found three destinations aiming at pages that do not exist: + # live URLs answering a 308 into a 404, which browsers cache and crawlers + # treat as final. The gate resolves every destination the way Fumadocs + # routes it, requires a wildcard destination's target directory to exist, # and rejects chains (a destination itself matched by a source), following # Next's own matching semantics. # @@ -3607,6 +3597,110 @@ jobs: - name: Changeset-family gate self-tests run: pnpm check:changeset-gate-self-tests + # ── NOT GATES: the unmeasured-tail reporter pair, per lane (#22991). The + # reporter finds its own job by runner name, so each lane counts its own + # tail. Argued in full at the end of `lint-eslint`. ────────────────────── + - name: Unmeasured-gate-tail reporter self-test + if: failure() + run: node scripts/report-unmeasured-gate-tail.mjs --self-test + + # Runs BECAUSE something failed. On a green head `failure()` is false, so + # it is skipped and contributes zero bytes to the log, the summary and the + # annotations — measured, not assumed. It always exits 0: the job is + # already failed when it runs, and a non-zero exit here would only add a + # second annotation pointing at the reporter instead of at the gate. + - name: Report how many gates never ran + if: failure() + env: + GITHUB_TOKEN: ${{ github.token }} + # ⚠️ Must equal this step's own `name:` above. The reporter subtracts + # it so that it does not list ITSELF among the steps that never ran — + # measured in run 34584689309, where it did exactly that, because the + # jobs API had not yet marked it `in_progress`. Drift between the two + # lines costs one extra, visibly silly line in the report; it cannot + # go wrong quietly. + OS_TAIL_REPORT_STEP: Report how many gates never ran + run: node scripts/report-unmeasured-gate-tail.mjs + + lint-ci-engine: + # Lane 4 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the + # cut). The always-on gates that ran from the lockstep guard to the end of + # the single job, less the engine test-double gate (in `lint-eslint`): + # the release and workflow contracts, the CI and test-infrastructure + # self-tests (the aggregator-roster and required-context pins among + # them), the engine and packaging gates, and the issue-citation gate, + # which is why this lane alone reads issues and pull requests. + name: Lint · CI, release & engine gates + runs-on: ubuntu-latest + # JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`' + # scheme. Body after the checkout, rebuilt per run from the 70 runs the + # `lint` job cites: max 3.8 minutes; 2 × 3.8 = 7.6, 10 on the floor. + # 20 + 10 = 30. + timeout-minutes: 30 + permissions: + contents: read + # The unmeasured-tail reporter at the end of this lane: `lint-eslint` + # argues the scope. + actions: read + # ⭐ Added by #18224, for the issue-citation step far below: it resolves + # the numbers THIS change cites against the board. Read-only and one + # scope wide — no issue, comment, label or assignee is ever written. It + # is spelled even though this board is PUBLIC, because an explicit + # `permissions:` block sets every unnamed scope to `none`, and a BLOCKING + # gate whose transport depends on repository visibility is a gate that + # goes red on a settings change no file here can assert. + issues: read + # ⭐ AND `pull-requests`, which is NOT redundant with the row above — + # MEASURED, on run 35495222460 of `half-state-patrol.yml`, whose token + # carries `issues` and NOT this scope: its census reported 3,628 + # unresolvable citation sites where a full-scope read of the same tree + # reported 2,168, and the difference is 1,460 — EXACTLY the + # `resolves-as-pull-request` tally. `GET /repos/{owner}/{repo}/issues` + # answers with the pull requests omitted, so every citation naming a PR + # number reads as a number the board never had. On THIS blocking step + # that is a false RED on a correct citation, and the frontier probe + # (`?per_page=1&sort=created&direction=desc`) is read from the same + # response — so an understated frontier turns later numbers into + # `never-issued` too. ⛔ Do not drop this row as tidying. + pull-requests: read + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + # CHECKOUT BUDGET: `typecheck-source-gates` sizes it; the `lint` job + # above says why every lane carries it. + timeout-minutes: 20 + with: + # The slot-lookup ratchet compares the baseline against its state at + # the merge base with main — the only way to see a file being ADDED + # to the grandfather list. A shallow clone has no merge base, and the + # check would degrade to "not verified" on every run. + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v6 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-v3- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + # Lockstep package-count guard (#17039). `.changeset/config.json`'s # `fixed` array is the one true count of packages one stray `major` # promotes; check-changeset-fixed.mjs (release/RC workflows) already @@ -3615,7 +3709,8 @@ jobs: # content/docs/protocol/backward-compatibility.mdx and one header # comment in scripts/publish-smoke-pack.mjs — so all three drifted # silently (69 written, 70 true) the day a package joined the group. - # Full-repo state, not diff-shaped, so — unlike the self-tests above — + # Full-repo state, not diff-shaped, so — unlike the changeset-family + # self-tests (`lint-docs-code`) — # this runs its real check every time, unconditionally. - name: Lockstep package-count guard run: pnpm check:lockstep-package-count @@ -4092,9 +4187,10 @@ jobs: - name: Gate-family selection self-test run: pnpm check:select-gate-families - # Aggregator roster gate (#10490). Three required contexts are aggregate + # Aggregator roster gate (#10490). Four required contexts are aggregate # jobs standing in for a set of real jobs — `Test Core` and `Dogfood - # Regression Gate` in ci.yml, `TypeScript Type Check` in this file — and + # Regression Gate` in ci.yml, `TypeScript Type Check` and, since #22991, + # `Lint & Repo Gates` in this file — and # each decides its verdict from what it lists in `needs:`. 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 @@ -4536,25 +4632,9 @@ jobs: - name: Manifest repository.directory guard run: pnpm check:manifest-repository-directory - # Engine test-double contract gate (#4550, from #4434). A test double - # LOOSER than the implementation it replaces turns a green suite into no - # suite at all, silently, on exactly the paths a double was introduced - # for. #4434 is the worked example: DELETE /sharing/rules/:idOrName - # answered 500 for every rule and both address forms from the day it was - # written, while `deleteRule drops rule + all its grants` asserted success - # against a fake engine that accepted the one call shape ObjectQL.delete - # refuses. This holds every fake ObjectQL engine's `delete` to the real - # dispatch predicate — imported from @objectstack/objectql, not - # hand-mirrored, so it cannot drift — with the pre-existing fakes in a - # shrink-only, measured baseline. Static AST only, so it needs no build - # and belongs in this job. Runs its own --self-test first: the detector - # can be broken while every fake is fine, and a scan that quietly stops - # matching would report OK while reading nothing (#4868's family). - - name: Engine test-double contract gate - run: pnpm check:engine-double-contract - # WHERE-matcher conformance gate (#8494, from #7620). The read-side - # sibling of the gate above, and the half its header lists as not + # sibling of the engine test-double gate (in `lint-eslint` since #22991), + # and the half its header lists as not # covered. #7620 corrected sixteen in-memory `matches(row, where)` # doubles that short-circuited on `$or` and discarded every sibling # equality key — turning a conjunction into a different query while the @@ -5077,42 +5157,789 @@ jobs: - name: Duration-shaped spec keys carry their unit in the key name run: pnpm --filter @objectstack/spec check:duration-unit-keys - # ── NOT GATES. The two steps below judge nothing about the tree. ────── - # - # ⭐ One red in this job is a LOWER BOUND on the number of problems in the - # tree, not a count — every gate behind the failing one never executed, - # and a gate that never executed produced no output, which is the same - # shape a passing gate has. The GATE ORDER note above `jobs:` already - # names this quantity ("the unmeasured tail") and is tuned to shrink it; - # until now nothing printed it. These steps print it. - # - # ⛔ The fix is NOT to make the skipped gates run: that would undo the - # adjudicated fail-fast order. ⛔ Nor is it `continue-on-error`, which - # trades a red for silence. The job's conclusion is untouched. - # - # Measured before this was written, on run 34468505490 of this very - # workflow (job 102842713265, failed at step #55): the jobs API and the web - # UI DO mark the un-run steps `skipped` — 112 of them there — but they - # never count them, and they use that same word for a step its own `if:` - # turned off, so the one surface that says it cannot tell the two apart. - # The check-run `output` was null on title, summary and text alike, and the - # job log — what an API or agent reader gets — said nothing at all. So what - # is added here is a machine-readable COUNT, on all three surfaces. + # ── NOT GATES: the unmeasured-tail reporter pair, per lane (#22991). The + # reporter finds its own job by runner name, so each lane counts its own + # tail. Argued in full at the end of `lint-eslint`. ────────────────────── + - name: Unmeasured-gate-tail reporter self-test + if: failure() + run: node scripts/report-unmeasured-gate-tail.mjs --self-test - # Offline, no token, no network: drives the reporter's judge() over - # recorded jobs-API shapes, and checks that the step below still hands - # itself its own name. Wired as a step because an unrun self-test is a - # phantom check. Sub-second. - # - # ⭐ `if: failure()` for the same reason the step below carries it, and it - # is the whole negative control: a green run of this job must be BYTE - # IDENTICAL to what it was before any of this existed. A self-test that - # ran unconditionally would print one line on every green run — one line - # is still new noise, and this change is not allowed to cost any. It is - # exercised on exactly the runs its subject is used on, which in this repo - # is not a rare event. ⛔ Do not "fix" this by dropping the condition. - # It fails LOUDLY: it is a separate step, so a broken reporter shows up as - # its own red step rather than as a missing paragraph. + # Runs BECAUSE something failed. On a green head `failure()` is false, so + # it is skipped and contributes zero bytes to the log, the summary and the + # annotations — measured, not assumed. It always exits 0: the job is + # already failed when it runs, and a non-zero exit here would only add a + # second annotation pointing at the reporter instead of at the gate. + - name: Report how many gates never ran + if: failure() + env: + GITHUB_TOKEN: ${{ github.token }} + # ⚠️ Must equal this step's own `name:` above. The reporter subtracts + # it so that it does not list ITSELF among the steps that never ran — + # measured in run 34584689309, where it did exactly that, because the + # jobs API had not yet marked it `in_progress`. Drift between the two + # lines costs one extra, visibly silly line in the report; it cannot + # go wrong quietly. + OS_TAIL_REPORT_STEP: Report how many gates never ran + run: node scripts/report-unmeasured-gate-tail.mjs + + lint-scoped-erasure: + # Lane 5 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the + # cut). Path-scoped families (#16496): the engine query-options erasure + # ratchet, the heaviest scoped gate, and the declared-population gate. + # Like every scoped lane it runs the gate-family selector first (its full + # argument is in this lane) and pays its setup even when the selector + # skips everything in it: about a minute of runner, not of wall. + name: Lint · scoped · query-options erasure + runs-on: ubuntu-latest + # JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`' + # scheme. Body after the checkout, rebuilt per run from the 70 runs the + # `lint` job cites: max 4.3 minutes; 2 × 4.3 = 8.7, 10 on the floor. + # 20 + 10 = 30. + timeout-minutes: 30 + permissions: + contents: read + # The unmeasured-tail reporter at the end of this lane: `lint-eslint` + # argues the scope. + actions: read + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + # CHECKOUT BUDGET: `typecheck-source-gates` sizes it; the `lint` job + # above says why every lane carries it. + timeout-minutes: 20 + with: + # The slot-lookup ratchet compares the baseline against its state at + # the merge base with main — the only way to see a file being ADDED + # to the grandfather list. A shallow clone has no merge base, and the + # check would degrade to "not verified" on every run. + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v6 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-v3- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # ── Gate-family selection (#16496) ─────────────────────────────────── + # After #16453 this job is the merge queue's critical path (median 1468 s + # over the ten merge-group runs measured for #16496; the PM dispatch-gates + # self-test alone 597 s), and none of its expensive steps read a + # merge group's file surface: a docs-only group paid the full battery. + # So on `merge_group` and `pull_request` the NINE scoped families below + # -- each step carrying `if: steps.gate-families.outputs. != 'skip'` + # -- run only when the changed paths touch the files that family reads. + # (A tenth, `migration_registry` (#19753), retired with its gate in + # #22554: the registry it compared is no longer committed.) + # `push` on main and the scheduled full run keep the whole battery: the + # script runs everything for any event it does not scope. + # + # #19498 added four of the nine (`entry_guard`, + # `declared_population_live`, `bare_root_worklist`, + # `self_test_workflow_commands`) and narrowed `pm_dispatch_gates`, on + # ruling #208 on #19491 (R4) and the maintainer's sentence quoted in the + # selector's header: the tooling's self-tests were 18.6 minutes of the + # 27.4-minute job a three-file `packages/spec` PR paid. ⛔ Product + # ratchets and censuses stay unconditional -- the tenancy and + # tenant-audit censuses, the engine gates and everything else here judge + # product code and are not in that ruling. + # + # ⭐ The invariant is FAIL-OPEN, and it holds at both layers. The script + # runs every family when the base cannot be resolved, the diff fails or + # is empty, a path is one it does not classify (a new top-level + # directory, an unlisted root file), or any change is a deletion, rename + # or type change; a family is skipped ONLY when every changed path is + # positively classified into a class that family's DECLARED read-set + # excludes. For the other four -- both ratchets, the corpus walk and the + # verify-lock self-test -- that read-set is what the family provably + # reads. For the five tooling self-tests it is the tool's OWN INPUTS, + # which is a weaker claim; it is argued where it is declared, in the + # selector's header. + # The `!= 'skip'` spelling means an ABSENT output -- the selector never + # ran, or wrote nothing -- also runs the step. Both halves are pinned by + # `scripts/ci/select-gate-families.selftest.sh` (`check:select-gate- + # families`, further down this job), which also reads THIS file and + # refuses any other `if:` spelling and any family the script does not + # decide. The per-family read-sets, and how each was measured, are in + # the script's header. + # + # The selection itself lives in scripts/ci/select-gate-families.sh: + # environment in, `$RUNNER_TEMP/gate-families.txt` + `$GITHUB_OUTPUT` + # out, and a ran/skipped table in the job summary (the seat reads it). + # This step only exports the event's fields, so the same script -- every + # branch of it -- runs locally and under its self-test. + # + # Since #22991 the gates run in lanes, and this step runs once in EACH + # lane that carries a scoped step: a step `if:` can read only its own + # job's step outputs, so the nine `if:` lines below stay byte-identical + # and each lane selects for itself. The script reads only the diff and + # is idempotent, and it cost 0 s at the median (1 s p90) over the 70 + # runs the `lint` job cites. A separate families job would instead put a + # second full-history checkout on the critical path of every scoped lane. + - name: Select the gate families this run pays for + id: gate-families + env: + OS_GATE_EVENT_NAME: ${{ github.event_name }} + OS_GATE_PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + OS_GATE_MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} + run: | + bash scripts/ci/select-gate-families.sh + + # Engine query-options erasure ratchet (#4918). The slot-lookup rule + # (`lint-scoped-slot-corpus` since #22991) protects the service LOOKUP; this one protects what you pass to the + # service you looked up. `IDataEngine.find/findOne/count/aggregate` declare + # their options as `EngineQueryOptions` & co., and for an internal caller + # `tsc` is the ONLY channel enforcing them — the protocol's ingress + # normalizer never sees a direct engine call, and the options schemas are + # not `.strict()`, so an unknown key is silently DROPPED. #4674 is the + # bill: two queries sorted by `direction` instead of `order`, both with a + # `limit`, so both returned the OLDEST rows — audit history that never + # showed recent changes, and a search that truncated away fresh records. + # #4720 restored those two sites and #4721 closed the external callers; + # this stops the shape regrowing internally. Same ratchet mechanism as + # slot-lookup for the non-test residual, plus one aggregate decrease-only + # number for test code (a test whose subject IS off-contract engine input + # must be able to build it). Runs its own --self-test first. + # + # Scoped (#16496): the same population and inputs as the slot-lookup + # ratchet, with `scripts/query-options-erasure-baseline.json`. + - name: Engine query-options erasure ratchet + if: steps.gate-families.outputs.query_options_erasure != 'skip' + run: pnpm check:query-options-erasure + + # The other half of the same failure (#13519). The watch-hint literal step + # (`lint-repo-tooling` since #22991) holds that a + # declaration is still READABLE BY A TEXT SCANNER; this one holds that + # what it reads still names something. A family whose declared literals + # all reach zero tracked files prints as an ORDINARY SILENCE — byte for + # byte the output of a gate that declared nothing — so the derivation and + # the gate disagree about whether a population was declared and neither + # says so. Measured on the tree this landed against: two families, one of + # them a gate that sweeps every workflow file and appeared on no card that + # edited one. The rule is per-FAMILY, not per-literal: a gate may spell a + # slug or a sentinel path beside a live declaration, and only a whole + # declaration that reaches nothing is refused. No allowlist and no gate + # names — the stronger rule ("a gate that enumerates a directory must + # declare one") was implemented, measured at 86 findings over 114 + # enumerating gate files, and refused as an allowlist with a verdict + # attached. Reads the derivation once over the tracked corpus; ~5s of + # work, measured at 0.40 min as a step on run 35506407130. + # + # Scoped (#19498): it imports `discoverFamilies` and `trackedFiles` from + # the dispatch derivation, so its inputs are the workflow tree, every + # gate source that discovery resolves, and the tracked NAME set — and + # only a name that DISAPPEARS can turn a live declaration dead, which is + # a structural change the selector already runs everything for. A group + # confined to product source, tests, docs or changesets skips it here and + # pays for it on `push` to main and on the hourly run. + - name: A declared gate population reaches the tree + if: steps.gate-families.outputs.declared_population_live != 'skip' + run: pnpm check:declared-population-live + + # ── NOT GATES: the unmeasured-tail reporter pair, per lane (#22991). The + # reporter finds its own job by runner name, so each lane counts its own + # tail. Argued in full at the end of `lint-eslint`. ────────────────────── + - name: Unmeasured-gate-tail reporter self-test + if: failure() + run: node scripts/report-unmeasured-gate-tail.mjs --self-test + + # Runs BECAUSE something failed. On a green head `failure()` is false, so + # it is skipped and contributes zero bytes to the log, the summary and the + # annotations — measured, not assumed. It always exits 0: the job is + # already failed when it runs, and a non-zero exit here would only add a + # second annotation pointing at the reporter instead of at the gate. + - name: Report how many gates never ran + if: failure() + env: + GITHUB_TOKEN: ${{ github.token }} + # ⚠️ Must equal this step's own `name:` above. The reporter subtracts + # it so that it does not list ITSELF among the steps that never ran — + # measured in run 34584689309, where it did exactly that, because the + # jobs API had not yet marked it `in_progress`. Drift between the two + # lines costs one extra, visibly silly line in the report; it cannot + # go wrong quietly. + OS_TAIL_REPORT_STEP: Report how many gates never ran + run: node scripts/report-unmeasured-gate-tail.mjs + + lint-scoped-slot-corpus: + # Lane 6 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the + # cut). Path-scoped families (#16496): the slot-lookup ratchet, the + # entry-guard sweep and the comment-mask corpus walk. The slot-lookup + # ratchet is the gate the checkout step's `fetch-depth: 0` note names. + name: Lint · scoped · slot lookup & corpus + runs-on: ubuntu-latest + # JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`' + # scheme. Body after the checkout, rebuilt per run from the 70 runs the + # `lint` job cites: max 4.3 minutes; 2 × 4.3 = 8.6, 10 on the floor. + # 20 + 10 = 30. + timeout-minutes: 30 + permissions: + contents: read + # The unmeasured-tail reporter at the end of this lane: `lint-eslint` + # argues the scope. + actions: read + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + # CHECKOUT BUDGET: `typecheck-source-gates` sizes it; the `lint` job + # above says why every lane carries it. + timeout-minutes: 20 + with: + # The slot-lookup ratchet compares the baseline against its state at + # the merge base with main — the only way to see a file being ADDED + # to the grandfather list. A shallow clone has no merge base, and the + # check would degrade to "not verified" on every run. + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v6 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-v3- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Gate-family selection (#16496) — the same step, once per lane that carries a + # scoped step: a step `if:` reads its OWN job's step outputs, so each such lane + # selects for itself. Argued in full in `lint-scoped-erasure`. + - name: Select the gate families this run pays for + id: gate-families + env: + OS_GATE_EVENT_NAME: ${{ github.event_name }} + OS_GATE_PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + OS_GATE_MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} + run: | + bash scripts/ci/select-gate-families.sh + + # Slot-lookup sweep ratchet (#4251). `pnpm lint` (`lint-eslint`) bans erasing a + # service-lookup result to `any` across packages/, but the files still + # holding pre-existing sites are grandfathered by path — and an ignored + # file is ignored COMPLETELY, so new erasures added to one ride the old + # entry in silence. This re-measures those files with the grandfathering + # lifted and holds them to a per-file count, so the list can only shrink. + # + # Scoped (#16496): reads `packages/**/*.{ts,tsx,mts,cts}`, its baseline, + # `eslint.config.mjs` and the top-level `scripts/*.mjs` it imports; a + # group touching none of those skips it (selection step above). + - name: Slot-lookup ratchet + if: steps.gate-families.outputs.slot_lookup != 'skip' + run: pnpm check:slot-lookup + + # Every `scripts/**` entry guard goes through ONE predicate (#10086). + # The hand-typed forms of "did node run me, or did someone import me?" + # had drifted into ELEVEN spellings across 33 files, and NINE were wrong + # in the same invisible direction: node resolves symlinks for the module + # graph but leaves `process.argv[1]` as the caller typed it, so a script + # reached through a symlink compared two different paths, answered false, + # and did NOTHING — exit 0, no output. The CI wrappers here hold the + # child's exit STATUS only, so an inert child read as a green gate; the + # governed-surface register (`scripts/pm/check-governed-merges.mjs`) was + # among the affected, where the inert run and its "NOT governed, ordinary + # queue landing applies" verdict are the SAME exit code. + # The sweep alone would not have held — nothing stopped a twelfth + # spelling. This gate is the part that closes the class: only + # `scripts/invoked-as.mjs` may read `process.argv[1]`, and that module's + # own self-test drives a real probe through a real symlink. Rationale and + # the rejected behavioural-sweep alternative: the gate script's header. + # Scans ~115 files, no spawns; ~0.2s of work, measured at 0.62 min as a + # step on run 35506407130. + # + # Scoped (#19498): its population is `scripts/**` — its own + # ROOT_DIR_WATCH_HINTS declaration, held against the root it really walks + # by its own self-test — so a group that changes no file under scripts/ + # (and no root configuration) skips it. The selection step at the top of + # this job decides that, `push` on main and the hourly run keep it + # unconditional, and the ruling that authorizes moving a tooling + # self-test off the PR path is quoted in the selector's header. + - name: scripts/ entry guards go through one predicate + if: steps.gate-families.outputs.entry_guard != 'skip' + run: pnpm check:entry-guard + + # The comment-mask CORPUS sweep (#10640) — the other instrument for the + # module the entry and parse guards delegate "is this span a comment, or code?" + # to. `js-comment-mask.mjs --self-test` pins the SHAPES someone wrote + # down; this parses every .{ts,tsx,mts,cts,js,mjs,cjs,jsx} file in the + # tree with @typescript-eslint/parser and diffs its comment ranges + # against the mask's, byte for byte. Neither subsumes the other, and that + # is measured in both directions on this tree rather than assumed: + # deleting the `{` counting inside `${...}` fails a pinned case and the + # sweep reads 0 disagreements over 4,741 files (the tree does not write + # that shape), while dropping `return` from REGEX_AFTER_KEYWORD passes all + # 23 pinned cases and the sweep names check-test-source-alias.mjs, where + # `return /(^|[^a-z])dist\//` is written today. + # The defect this would have caught shipped: 16 files disagreeing, 15 of + # them reading commented-out text as live code, 10,252 comment bytes in + # one file — and the instrument that found it lived in an agent's + # scratchpad, which is why re-deriving it was impossible until this file. + # WHY A HARD GATE ON EVERY PR, and not an on-demand flag: the sweep's + # verdict depends on the CORPUS as much as on the masker, so a PR that + # writes a shape the mask reads wrong turns ~20 source-scanning gates + # into liars on that file, on a PR that never touched the masker. That + # arrival is not detectable by any convention a human or an agent has to + # remember at the right moment — the failure mode this repo has already + # paid for at #4690 and again here. + # WHY A STEP AND NOT A JOB: a new job's `name:` is a required-context + # contract (scripts/check-required-contexts.mjs) that only a + # maintainer-present Settings change can enrol, and an unenrolled job is + # an advisory gate — #5617 verbatim. This job is already required. + # Invoked as `node` rather than `pnpm check:*`, same as the other + # `node scripts/…` steps in this lane: see the GATE INVOCATION IDIOM note + # at the top of this file. + # Runs its own comparator self-test first — 12 cases proving the + # comparison can still REPORT, because "0 files disagree" and "the + # comparison is broken" otherwise print the same line. + # Cost, measured on a contended 4-vCPU container: 4,741 files, 72 MB of + # source, 45–48 s, of which the parser is 45 s. The step prints CI's own + # number on every run. + # + # Scoped (#16496): the walk reads every `.ts .tsx .mts .cts .js .mjs + # .cjs .jsx` file in the tree, so a group that changes no file of those + # extensions (and no root configuration) skips it -- selection step at + # the top of this job. + - name: Comment mask agrees with a real parser over the whole corpus + if: steps.gate-families.outputs.comment_mask_corpus != 'skip' + run: node scripts/check-comment-mask-corpus.mjs + + # ── NOT GATES: the unmeasured-tail reporter pair, per lane (#22991). The + # reporter finds its own job by runner name, so each lane counts its own + # tail. Argued in full at the end of `lint-eslint`. ────────────────────── + - name: Unmeasured-gate-tail reporter self-test + if: failure() + run: node scripts/report-unmeasured-gate-tail.mjs --self-test + + # Runs BECAUSE something failed. On a green head `failure()` is false, so + # it is skipped and contributes zero bytes to the log, the summary and the + # annotations — measured, not assumed. It always exits 0: the job is + # already failed when it runs, and a non-zero exit here would only add a + # second annotation pointing at the reporter instead of at the gate. + - name: Report how many gates never ran + if: failure() + env: + GITHUB_TOKEN: ${{ github.token }} + # ⚠️ Must equal this step's own `name:` above. The reporter subtracts + # it so that it does not list ITSELF among the steps that never ran — + # measured in run 34584689309, where it did exactly that, because the + # jobs API had not yet marked it `in_progress`. Drift between the two + # lines costs one extra, visibly silly line in the report; it cannot + # go wrong quietly. + OS_TAIL_REPORT_STEP: Report how many gates never ran + run: node scripts/report-unmeasured-gate-tail.mjs + + lint-scoped-tooling: + # Lane 7 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the + # cut). Path-scoped families (#16496): the PM bare-root worklist + # self-test, the self-test workflow-command gate and the verify-lock + # entry-point self-test. + name: Lint · scoped · tooling self-tests + runs-on: ubuntu-latest + # JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`' + # scheme. Body after the checkout, rebuilt per run from the 70 runs the + # `lint` job cites: max 3.7 minutes; 2 × 3.7 = 7.5, 10 on the floor. + # 20 + 10 = 30. + timeout-minutes: 30 + permissions: + contents: read + # The unmeasured-tail reporter at the end of this lane: `lint-eslint` + # argues the scope. + actions: read + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + # CHECKOUT BUDGET: `typecheck-source-gates` sizes it; the `lint` job + # above says why every lane carries it. + timeout-minutes: 20 + with: + # The slot-lookup ratchet compares the baseline against its state at + # the merge base with main — the only way to see a file being ADDED + # to the grandfather list. A shallow clone has no merge base, and the + # check would degrade to "not verified" on every run. + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v6 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-v3- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Gate-family selection (#16496) — the same step, once per lane that carries a + # scoped step: a step `if:` reads its OWN job's step outputs, so each such lane + # selects for itself. Argued in full in `lint-scoped-erasure`. + - name: Select the gate families this run pays for + id: gate-families + env: + OS_GATE_EVENT_NAME: ${{ github.event_name }} + OS_GATE_PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + OS_GATE_MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} + run: | + bash scripts/ci/select-gate-families.sh + + # PM bare-root worklist self-test (#10840). The PM dispatch-gates + # self-test proves the + # dispatch derivation still WORKS; this one proves the recorded triage of + # the gates that derivation structurally cannot see is still true of the + # tree. A gate whose population is spelled as a bare single-segment word + # builds no watch hint at all — not a dead hint, not a silent verdict, + # nothing — so it is unnameable by any dispatch brief and leaves no residue + # saying so. The worklist enumerates that species and carries one recorded + # verdict per row; this step fails when a verdict outlives its row (the + # shrink) or when a new gate joins the species unjudged. + # + # ⛔ It runs the SELF-TEST only, never the sweep as a verdict: recognising + # this species needs a heuristic over constant NAMES, and #10705 refused to + # put one on the path that derives every PR's gate list. Nothing in + # `dispatch-gates.mjs` reads the worklist, and no verdict in it reaches a + # dispatch prompt. + # + # Reads the workflow tree and every gate source once; ~0.5s of work, + # measured at 0.32 min as a step on run 35506407130. + # + # ⚠️ This step WAS unconditional, on the reasoning that a self-test which + # can be skipped is the gap moving rather than closing. #19498 scopes it + # anyway, and the trade is explicit rather than reasoned away: it imports + # the same derivation as the dispatch-gates and watch-hint literal steps, so its inputs are the + # workflow tree and the gate sources, and on a group touching neither the + # gap does move — to `push` on main and the hourly full run, which keep + # the whole battery. Gate weakening is a maintainer floor; the sentence + # that authorizes this one is quoted in the selector's header. + - name: PM bare-root worklist self-test + if: steps.gate-families.outputs.bare_root_worklist != 'skip' + run: node scripts/pm/bare-root-worklist.mjs --self-test + + # Self-test output vs. the runner's command parser (#11886). Sibling of + # the self-test wiring gate (`lint-repo-tooling`) and sharing its + # population: that one asks whether CI RUNS + # each self-test, this one asks what each self-test PRINTS while it does. + # + # The defect it closes was live on every PR in this repo. A line of + # `scripts/pm/ci-failure.mjs`'s self-test prose named a workflow-command + # token literally, inside backticks, as documentation of what that tool + # anchors on. The runner does not know a token is being quoted: it parsed + # it and minted `annotation_level: failure` on a check run whose + # conclusion was SUCCESS. On `d63b01436` that artifact was the ONLY + # annotation the `Lint & Repo Gates` run carried. A failure-level + # annotation on a green required check is a claim that something failed, + # and a repo that mints one on every PR is teaching its readers to skim + # exactly the surface other gates report findings into. + # + # ⚠️ INVISIBLE TO EVERY LOCAL RUN, which is the whole reason it is a gate: + # the text is ordinary prose on disk and in a terminal, and only a runner + # transforms it. It was found by reading one job's annotations through the + # API, not by anything in this file. + # + # Two facts were measured on real runs and the gate's shape follows them: + # the legacy `##[...]` form is parsed ANYWHERE in a printed line (the + # measured token sat at column 18 and was still consumed), while the + # `::...::` form is parsed only at LINE START (two mid-sentence ones print + # on every PR from `check-prerelease-pin-watch` and mint nothing). So the + # first detector is unanchored and the second is anchored — flagging inert + # prose would be a false positive the next author would be right to delete. + # + # It RUNS the self-tests rather than reasoning about their source, because + # "is this token printed?" is only answerable by printing it. A static + # prefilter picks which ones to run — over-selecting freely, since the + # verdict never comes from it — which is what keeps this step at ~14 s + # instead of the ~4 min the whole population costs. + # + # Invoked as `node scripts/…` rather than through a `pnpm check:*` alias: + # see the GATE INVOCATION IDIOM note at the top of this file. Reads + # `scripts/` and `.github/workflows/` off disk and spawns the selected + # self-tests; no network. Measured at 0.48 min as a step on run + # 35506407130. + # + # Scoped (#19498): its declared population is + # `scripts/**/*.mjs`, `scripts/**/*.mts` and `scripts/**/*.sh`, and the + # rest of its read-set is the workflow tree and `.github/actions` it + # discovers the runnable self-tests from — so a group touching none of + # those skips it here and runs it on `push` to main and on the hourly + # full run. + - name: Self-test workflow-command gate + if: steps.gate-families.outputs.self_test_workflow_commands != 'skip' + run: | + node scripts/check-self-test-workflow-commands.mjs --self-test + node scripts/check-self-test-workflow-commands.mjs + + # Verify-lock entry-point self-test (#9661). `scripts/pm/os-verify-lock.sh` + # is the ONE way an agent takes the container's shared heavy-verify lock, + # and it is the enforcement of a rule that used to live only in prose: the + # acquisition budget os-dev.md declares is now unrepresentable above its + # cap, grants are ordered by a ticket file, and every run reports how long + # it held. Measured before it existed, in one container: five live waiters, + # three of them 6x over the declared cap, and the compliant ones were the + # ones not verifying — a convention whose violators win is not a convention. + # + # Nothing in CI *uses* the lock (GitHub runners are one job per machine), + # so this step exists to keep the mechanism honest rather than to serialise + # anything: 48 cases over the budget clamp, ticket liveness (dead pid, + # REUSED pid, over-age), fd hygiene, exit-code passthrough, and real + # multi-process contention — queue timeout, legacy free-hand coexistence in + # both directions, and three staggered waiters acquiring in arrival order. + # + # Since #10289 it also guards the BASH 3.2 FLOOR and the bounded-refusal + # rule, which is why the count moved: `/usr/bin/env bash` is 3.2.57 on + # macOS, and a bash 4+/5+ construct there does not fail loudly — it makes + # the acquisition deadline unreachable and turns the wait into an unbounded + # spin that prints no VERDICT at all. So the suite now scans the + # acquisition path for 4+/5+ constructs, runs a real acquisition with + # EPOCHSECONDS/EPOCHREALTIME unset and mapfile disabled, and asserts that + # an unusable host (no `flock`) and a stopped clock each REFUSE with a + # verdict and a non-zero exit rather than waiting forever. + # + # It runs entirely on a private lock under a temp dir; it never touches + # /tmp/os-heavy-verify.lock, so a runner and an agent container behave the + # same. No network; median 100 s over the ten merge-group runs measured + # for #16496. + # + # Scoped (#16496): the suite reads the script itself (`$SELF`) and a + # private temp dir, and its case (h) runs the real entry point from the + # repo root, which routes through filter_preflight -> + # scripts/pnpm-filter-targets.mjs --preflight and so reads the top-level + # scripts/*.mjs helpers that imports, pnpm-workspace.yaml and every + # workspace package.json. A group touching none of those skips it. That + # is not the exemption the self-tests above refuse (a label or a paths + # filter with no reader behind it): the selection step at the top of + # this job runs this step on every doubt, and `push` on main keeps it + # unconditional. + - name: Verify-lock entry-point self-test + if: steps.gate-families.outputs.verify_lock != 'skip' + run: bash scripts/pm/os-verify-lock.sh --self-test + + # ── NOT GATES: the unmeasured-tail reporter pair, per lane (#22991). The + # reporter finds its own job by runner name, so each lane counts its own + # tail. Argued in full at the end of `lint-eslint`. ────────────────────── + - name: Unmeasured-gate-tail reporter self-test + if: failure() + run: node scripts/report-unmeasured-gate-tail.mjs --self-test + + # Runs BECAUSE something failed. On a green head `failure()` is false, so + # it is skipped and contributes zero bytes to the log, the summary and the + # annotations — measured, not assumed. It always exits 0: the job is + # already failed when it runs, and a non-zero exit here would only add a + # second annotation pointing at the reporter instead of at the gate. + - name: Report how many gates never ran + if: failure() + env: + GITHUB_TOKEN: ${{ github.token }} + # ⚠️ Must equal this step's own `name:` above. The reporter subtracts + # it so that it does not list ITSELF among the steps that never ran — + # measured in run 34584689309, where it did exactly that, because the + # jobs API had not yet marked it `in_progress`. Drift between the two + # lines costs one extra, visibly silly line in the report; it cannot + # go wrong quietly. + OS_TAIL_REPORT_STEP: Report how many gates never ran + run: node scripts/report-unmeasured-gate-tail.mjs + + lint-pm-dispatch-gates: + # Lane 8 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the + # cut). One path-scoped family, alone: the PM dispatch-gates self-test. + # On `pull_request` and `merge_group` it runs the battery's fast tier; on + # `schedule` and `push` both tiers, the slow tier's daily home under the + # ruling (`scripts/pm/check-dispatch-gates.mjs` decides, from the event). + name: Lint · PM dispatch-gates + runs-on: ubuntu-latest + # JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`' + # scheme. Body after the checkout, rebuilt per run from the 70 runs the + # `lint` job cites, where every run of this gate was BOTH tiers: max 15.5 + # minutes; 2 × 15.5 = 31.0, 30 on the grain. 20 + 30 = 50, under the 55 + # cap the `lint` job argues. Both tiers still run here on `schedule` and + # `push`, so the wall is sized for them, not for the fast tier. + timeout-minutes: 50 + permissions: + contents: read + # The unmeasured-tail reporter at the end of this lane: `lint-eslint` + # argues the scope. + actions: read + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + # CHECKOUT BUDGET: `typecheck-source-gates` sizes it; the `lint` job + # above says why every lane carries it. + timeout-minutes: 20 + with: + # The slot-lookup ratchet compares the baseline against its state at + # the merge base with main — the only way to see a file being ADDED + # to the grandfather list. A shallow clone has no merge base, and the + # check would degrade to "not verified" on every run. + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v6 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-v3- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Gate-family selection (#16496) — the same step, once per lane that carries a + # scoped step: a step `if:` reads its OWN job's step outputs, so each such lane + # selects for itself. Argued in full in `lint-scoped-erasure`. + - name: Select the gate families this run pays for + id: gate-families + env: + OS_GATE_EVENT_NAME: ${{ github.event_name }} + OS_GATE_PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + OS_GATE_MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} + run: | + bash scripts/ci/select-gate-families.sh + + # PM dispatch-gates self-test (#8162). `scripts/pm/dispatch-gates.mjs` + # derives the "local gates for this card" line of every dispatch prompt, + # and carried a 61-case --self-test that NO job ran: it executed only when + # a human or an agent typed it. A break in the extraction functions landed + # green and surfaced later as a prompt naming the wrong gate families — + # output that reads as correct, produced by the one tool whose purpose is + # to stop gate lists being memory-shaped. Same family as the + # `Changeset-family gate self-tests` step above. Until #16496 this step + # was deliberately UNCONDITIONAL -- no `if:`, no label read, no paths + # filter -- on the argument that an exemption is what a self-test must + # not have, or the gap moves. That argument was about an exemption a + # PATH FILTER or a LABEL would grant: a paths: trigger or a label read + # skips the step on a card that never touched the paths someone once + # listed, with no reader of what the test actually reads. What runs + # here now is different in kind and was the maintainer's call (#16496, + # 「同意你的建议,你负责执行派发所有可行的优化」): the selection step at + # the top of this job classifies every changed path against a declared + # read-set for this step, and skips it when no changed path is in that + # set. Every doubt runs it, the self-test of the selector pins that, and + # `push` on main keeps it unconditional. + # + # ⚠️ Since #19498 that read-set is NARROWER than what the battery reads, + # and the gap is stated rather than papered over. The read-set is the + # tool's own inputs: every workflow and composite action, every gate + # source under `scripts/**` and a workspace package's own `scripts/`, + # every `package.json`, `.claude/**`, `skills/**`, `AGENTS.md`, + # `CLAUDE.md` and root configuration. The battery ALSO reads the CONTENT + # of every JS/TS and `.sh` file in the tree (the compound-anchor census + # of `function ...SelfTest...(` declarations and the exposed-scratch-dir + # sweep of every mkdtempSync/mkdirSync caller, with the nested + # `.gitignore` files it consults) and the tracked NAME set it sweeps — + # which is why, until #19498, any added file anywhere ran it. On PR + # #19314 this step alone was 11.8 minutes of a 27.4-minute job for a + # three-file `packages/spec` diff, and ruling #208 on #19491 (R4) took + # those whole-tree reads off the PR path on the maintainer's sentence, + # quoted in the selector's header. ⛔ A defect in that wider set can now + # first appear on `main`; the push-on-main and hourly full runs are what + # bound it, and widening the skip further is again a maintainer call. + # + # ⚠️ Since #22991 the battery is TIERED by event, inside the gate rather + # than here: on `pull_request` and `merge_group` `pnpm check:pm-dispatch-gates` + # runs the fast tier; on `schedule`, `push` and any other event it runs + # both. Every case still runs at least hourly on the full run, and a + # slow-tier red is a red on `main`. The step below is unchanged; the + # decision and its argument live in `scripts/pm/check-dispatch-gates.mjs`. + # + # The gate runs the SELF-TEST only. The live derivation + # (`node scripts/pm/dispatch-gates.mjs `) answers a question about a + # card's file surface, is print-only, and exits 0 on any completed run — + # there is no verdict in it for CI to hold, only a slow read of the whole + # workflow tree. Why the gate is its own file rather than pointing + # `check:pm-dispatch-gates` straight at the tool is measured and argued in + # `scripts/pm/check-dispatch-gates.mjs`'s header (the tool's self-test + # fixtures are path strings, and they become watch hints that fabricate + # MATCHED leads across most of the tree). + # + # ⛔ This paragraph deliberately carries NO cost figure (#12831). The line + # that used to close it — "Reads two files and walks packages/; ~0.3s" — + # described the 61-case self-test named at the top, and the part that grew + # since is exactly the part it denied: the live-tree cases run the same + # discovery the tool does (every workflow file, and then the source of + # every gate that discovery finds), build temporary git repositories and + # drive `changedPathsFromGit` against them, and sweep the tracked corpus. + # That is not "two files", and it is not a walk of packages/. The estimate + # went with the description rather than being refreshed: a replacement has + # to be read off a real CI run of THIS step, because a reading taken in an + # agent container is a reading of a contended box and not of a runner + # (#12528). A cost nobody re-measures ends up endorsing a decision it has + # no standing to endorse, which is what this one had become. No live + # counts stand here either, for the same reason — they rot the same way, + # and it is the SHAPE of the work, not its size, that the argument needs. + # + # ⛔ Cost was never what decided this step's conditionality, and the + # measured medians that motivated #16496 (597 s here, over ten + # merge-group runs) live in that card and its PR, not in this note. + # Losing the estimate is NOT an argument for a label read or a paths + # filter, and the read-set-scoped `if:` below is not one of those: it + # was a maintainer call (the first paragraph names it), and widening the + # skip -- any change to the classes the selector treats as inert for + # this family -- is again a maintainer call, taken in + # scripts/ci/select-gate-families.sh under its self-test. + - name: PM dispatch-gates self-test + if: steps.gate-families.outputs.pm_dispatch_gates != 'skip' + run: pnpm check:pm-dispatch-gates + + # ── NOT GATES: the unmeasured-tail reporter pair, per lane (#22991). The + # reporter finds its own job by runner name, so each lane counts its own + # tail. Argued in full at the end of `lint-eslint`. ────────────────────── - name: Unmeasured-gate-tail reporter self-test if: failure() run: node scripts/report-unmeasured-gate-tail.mjs --self-test @@ -7084,8 +7911,9 @@ jobs: # moment a lane moves, and cannot say WHICH lane went missing. # # Two things read this list, which is why it is real YAML and not a comment: - # - `node scripts/check-aggregator-roster.mjs` (statically, in the lint - # job) asserts it equals this job's `needs:` EXACTLY, in both + # - `node scripts/check-aggregator-roster.mjs` (statically, in the + # `lint-ci-engine` lane of `Lint & Repo Gates` since #22991) asserts it + # equals this job's `needs:` EXACTLY, in both # directions. A fifth lane added to this workflow and forgotten here # would otherwise ride green as an ADVISORY check-run behind the # required "TypeScript Type Check" context — a job that appears in the diff --git a/scripts/check-aggregator-roster.mjs b/scripts/check-aggregator-roster.mjs index 3a3a8920a23..ceea16f01d6 100644 --- a/scripts/check-aggregator-roster.mjs +++ b/scripts/check-aggregator-roster.mjs @@ -10,10 +10,11 @@ * * ## The gap this closes * - * Three of this repo's required contexts are aggregator jobs standing in for a + * Four of this repo's 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, and `typecheck` / `TypeScript Type Check` in lint.yml. + * ci.yml, and `typecheck` / `TypeScript Type Check` and, since #22991, + * `lint` / `Lint & Repo Gates` in lint.yml. * * Nothing held those rosters to anything. The failure mode is one forgotten * line: @@ -67,7 +68,7 @@ * * Discovery is by declaration, so a NEW aggregator that adds the two keys is * covered without editing this file. `REQUIRED_AGGREGATORS` is the floor - * underneath that: the three jobs that carry a branch-protection-required + * underneath that: the four jobs that carry a branch-protection-required * context must each be found AND declared, so deleting the declaration to make * a red go away is itself red. * @@ -107,6 +108,8 @@ export const REQUIRED_AGGREGATORS = [ { workflow: 'ci.yml', job: 'test-gate', context: 'Test Core' }, { workflow: 'ci.yml', job: 'dogfood-gate', context: 'Dogfood Regression Gate' }, { workflow: 'lint.yml', job: 'typecheck', context: 'TypeScript Type Check' }, + // #22991: the single 193-step job became an aggregate over eight lanes. + { workflow: 'lint.yml', job: 'lint', context: 'Lint & Repo Gates' }, ]; /** A legal GitHub Actions job id. A comma or a stray quote fails here. */ @@ -495,6 +498,7 @@ async function selfTest() { battery('(2) POSITIVE CONTROL A, per aggregator'); const dropped = [ { label: 'typecheck loses a lane from needs:', file: 'lint.yml', member: 'typecheck-consumers', from: ' - typecheck-consumers\n', to: '' }, + { label: 'lint loses a lane from needs:', file: 'lint.yml', member: 'lint-pm-dispatch-gates', from: ' - lint-pm-dispatch-gates\n', to: '' }, { label: 'test-gate loses its matrix from needs:', file: 'ci.yml', member: 'test', from: ' needs: [test, filter]\n', to: ' needs: [filter]\n' }, { label: 'dogfood-gate loses the CLI pass from needs:', @@ -523,6 +527,7 @@ async function selfTest() { battery('(3) POSITIVE CONTROL B, per aggregator'); const phantom = [ { label: 'typecheck needs a job that was deleted', file: 'lint.yml', from: ' - typecheck-consumers\n', to: ' - typecheck-consumers\n - typecheck-ghost\n', ghost: 'typecheck-ghost' }, + { label: 'lint needs a job that was deleted', file: 'lint.yml', from: ' - lint-pm-dispatch-gates\n', to: ' - lint-pm-dispatch-gates\n - lint-ghost\n', ghost: 'lint-ghost' }, { label: 'test-gate needs a job that was deleted', file: 'ci.yml', from: ' needs: [test, filter]\n', to: ' needs: [test, filter, test-ghost]\n', ghost: 'test-ghost' }, { label: 'dogfood-gate needs a job that was deleted', @@ -561,13 +566,25 @@ async function selfTest() { // 5a. The declaration is unfindable: the whole env block is gone. battery('(5) REFUSALS -- the most important assertions in this file'); + // Anchored on the roster's FIRST MEMBER, not on the key alone: since #22991 lint.yml declares + // two rosters, and an unanchored pattern deletes whichever comes first in the file -- which + // silently retargeted this fixture at `lint` the day that job became an aggregate. const noDeclaration = fixture('typecheck loses its roster declaration', 'lint.yml', (s) => - s.replace(new RegExp(`\\n env:\\n ${MEMBERS_KEY}: [^\\n]*\\n`), '\n'), + s.replace(new RegExp(`\\n env:\\n ${MEMBERS_KEY}: typecheck-source-gates[^\\n]*\\n`), '\n'), ); assert( noDeclaration.problems.some((p) => p.includes("job 'typecheck'") && p.includes('declares no') && p.includes('refusing to report a pass')), `deleting an aggregator's declaration ⇒ REFUSAL naming the job -- got ${JSON.stringify(noDeclaration.problems)}`, ); + const noLintDeclaration = fixture('lint loses its roster declaration', 'lint.yml', (s) => + s.replace(new RegExp(`\\n env:\\n ${MEMBERS_KEY}: lint-eslint[^\\n]*\\n`), '\n'), + ); + assert( + noLintDeclaration.problems.some( + (p) => p.includes("job 'lint'") && p.includes("'Lint & Repo Gates'") && p.includes('declares no') && p.includes('refusing to report a pass'), + ), + `deleting the lint aggregate's declaration ⇒ REFUSAL naming the job and its required context -- got ${JSON.stringify(noLintDeclaration.problems)}`, + ); // 5b. The aggregator job itself is gone from the workflow. const noJob = fixture('test-gate is deleted from ci.yml', 'ci.yml', (s) => s.replace(/\n test-gate:\n/, '\n test-gate-renamed-away:\n')); @@ -578,7 +595,7 @@ async function selfTest() { // 5c. The declaration resolves to ZERO members. const emptyRoster = fixture('typecheck declares an empty roster', 'lint.yml', (s) => - s.replace(new RegExp(`${MEMBERS_KEY}: [^\\n]*`), `${MEMBERS_KEY}: ''`), + s.replace(new RegExp(`${MEMBERS_KEY}: typecheck-source-gates[^\\n]*`), `${MEMBERS_KEY}: ''`), ); assert( emptyRoster.problems.some((p) => p.includes('EMPTY') && p.includes('refusing to report a pass')), @@ -703,7 +720,7 @@ async function selfTest() { console.log( `✓ check-aggregator-roster --self-test: ${checked} assertions ` + `(baseline + a dropped member and a phantom needs: entry for each of the ${REQUIRED_AGGREGATORS.length} required aggregators + ` + - `six refusals + the --leg cross-check + the malformed-roster and laundered-non-member pins + the CI wiring).`, + `seven refusals + the --leg cross-check + the malformed-roster and laundered-non-member pins + the CI wiring).`, ); return SELF_TEST_VERDICT; diff --git a/scripts/check-required-contexts.mjs b/scripts/check-required-contexts.mjs index 90f399376ab..0bd352fdcea 100644 --- a/scripts/check-required-contexts.mjs +++ b/scripts/check-required-contexts.mjs @@ -912,6 +912,37 @@ function pinSteps(job) { return steps.filter((step) => runCommands(step).some((command) => invokes(command, 'pnpm', PIN_SCRIPT))); } +/** + * The jobs whose verdict the required `lint` context (`Lint & Repo Gates`) carries, as + * `[id, job]` pairs: the `lint` job itself and, since #22991 made it an aggregate, every lane + * it BOTH declares in `OS_AGGREGATOR_MEMBERS` AND lists in `needs:`. + * + * Both, not either. A lane that is only declared is not waited on, and a lane that is only in + * `needs:` is the roster drift `check:aggregator-roster` reds — so neither is a place the + * pin may live and still be called wired. Read from the parsed document, never from text, for + * the reasons `pinSteps` lists. An unreadable document or a missing `lint` job answers no + * jobs at all, so every presence assertion over it goes red rather than vacuously green. + * + * @param {unknown} doc a parsed lint.yml + * @returns {Array<[string, Record]>} + */ +function jobsCarriedByLint(doc) { + const jobs = doc && typeof doc === 'object' ? doc.jobs : undefined; + if (!jobs || typeof jobs !== 'object') return []; + const lint = jobs.lint; + if (!lint || typeof lint !== 'object') return []; + const carried = [['lint', lint]]; + const env = lint.env && typeof lint.env === 'object' ? lint.env : {}; + const declared = String(env.OS_AGGREGATOR_MEMBERS ?? '').trim().split(/\s+/).filter(Boolean); + const needs = Array.isArray(lint.needs) ? lint.needs.map(String) : typeof lint.needs === 'string' ? [lint.needs] : []; + for (const id of declared) { + if (!needs.includes(id)) continue; + const job = jobs[id]; + if (job && typeof job === 'object') carried.push([id, job]); + } + return carried; +} + /** * Judge a registry against already-parsed workflows. * @@ -3115,9 +3146,11 @@ async function selfTest() { // A PRESENCE assertion about a required context's own wiring, which makes // its error direction a silent GREEN: whatever satisfies it wrongly leaves // this gate reporting that the pin runs while it does not. `pinSteps` reads - // `jobs.lint.steps` and asks each step's `run:` whether it INVOKES the - // script; the three text shapes that used to satisfy this are catalogued - // there and pinned below in both directions. + // a job's steps and asks each step's `run:` whether it INVOKES the script; + // the three text shapes that used to satisfy this are catalogued there and + // pinned below in both directions. Since #22991 the `lint` job is an + // aggregate that runs no gate itself, so "the required job" is the set + // `jobsCarriedByLint` reads: `lint` and the lanes it declares AND needs. battery('is the pin WIRED into the required job?'); const lintDoc = parse(sources['lint.yml']); const lintJob = lintDoc?.jobs?.lint; @@ -3129,11 +3162,15 @@ async function selfTest() { 'wiring: lint.yml must declare a readable `lint` job — this pin cannot say anything about a job it could not read (#4690)', ); /** The seam the fixtures below pin: lint.yml SOURCE in, "is the pin wired" out. */ - const pinWiredIn = (source) => pinSteps(parse(source)?.jobs?.lint).length > 0; + const pinWiredIn = (source) => jobsCarriedByLint(parse(source)).some(([, job]) => pinSteps(job).length > 0); assert( pinWiredIn(sources['lint.yml']), - 'wiring: lint.yml\'s `lint` job (the "Lint & Repo Gates" context) must run `pnpm check:required-contexts` — an unwired pin verifies nothing (#4690)', + 'wiring: lint.yml\'s `lint` job (the "Lint & Repo Gates" context), or a lane it declares and needs, must run `pnpm check:required-contexts` — an unwired pin verifies nothing (#4690)', ); + /** Every carried job that hosts the pin, with the pin steps it hosts. */ + const hosts = jobsCarriedByLint(lintDoc) + .map(([id, job]) => ({ id, job, steps: pinSteps(job) })) + .filter((h) => h.steps.length > 0); // Unchanged in intent (#10877 left this one alone deliberately: it is an // ABSENCE assertion and was never satisfiable by prose), but it consumed // the text slice above, so it is re-homed onto the same structural read. @@ -3142,9 +3179,16 @@ async function selfTest() { // does not get pinned. Non-vacuous because the presence assertion above // establishes there is at least one. assert( - pinSteps(lintJob).every((step) => !Object.prototype.hasOwnProperty.call(step, 'if')), + hosts.every((h) => h.steps.every((step) => !Object.prototype.hasOwnProperty.call(step, 'if'))), 'wiring: the required-context pin step must carry NO `if:` — whatever a condition reads is a way for a PR to arrange that this pin does not run on it', ); + // …and, since the pin moved into a lane (#22991), neither may the LANE: a job-level `if:` is + // the same arrangement one level up, and it is the one a step-level read cannot see. The + // aggregate itself is exempt by construction — its `if: always()` is what makes it report. + assert( + hosts.every((h) => h.id === 'lint' || !Object.prototype.hasOwnProperty.call(h.job, 'if')), + 'wiring: the lane hosting the required-context pin must carry NO job-level `if:` — a skipped lane is a pin that did not run', + ); // ── the wiring recognizer, in BOTH directions ──────────────────────────── // @@ -3218,8 +3262,53 @@ async function selfTest() { ' run: pnpm typecheck', ), ), - `wiring: the pin running in a job that is NOT \`lint\` does not wire \`Lint & Repo Gates\` (#10877)`, + `wiring: the pin running in a job that is NOT \`lint\`, nor a lane \`lint\` aggregates, does not wire \`Lint & Repo Gates\` (#10877)`, ); + // (q) ⭐ #22991: the pin in a LANE of the `lint` aggregate — declared AND needed — is wired. + // The checked-in lint.yml is this shape; the fixture pins the reading apart from it. + const aggregateFixture = ({ declare, need, laneIf = null }) => + lintFixture( + ' lint:', + ' name: Lint & Repo Gates', + ...(need ? [' needs: [lint-lane]'] : []), + ...(declare ? [' env:', ' OS_AGGREGATOR_MEMBERS: lint-lane'] : []), + ' if: always()', + ' runs-on: ubuntu-latest', + ' steps:', + ' - name: Verify every lint lane succeeded', + ' run: node verify.mjs', + ' lint-lane:', + ...(laneIf ? [` if: ${laneIf}`] : []), + ' runs-on: ubuntu-latest', + ' steps:', + ' - name: Required-context name pin', + ` run: pnpm ${PIN_SCRIPT}`, + ); + assert( + pinWiredIn(aggregateFixture({ declare: true, need: true })), + `wiring: the pin in a lane the \`lint\` aggregate declares AND needs IS wiring (#22991)`, + ); + // (r) …but a lane that is only DECLARED is not waited on: the aggregate can report before + // it ends, so its pin is not part of the verdict. + assert( + !pinWiredIn(aggregateFixture({ declare: true, need: false })), + 'wiring: the pin in a lane the aggregate declares but does not need is NOT wiring (#22991)', + ); + // (s) …and a lane that is only NEEDED is the roster drift `check:aggregator-roster` reds; + // this gate does not call a lane wired that the roster gate calls a defect. + assert( + !pinWiredIn(aggregateFixture({ declare: false, need: true })), + 'wiring: the pin in a lane the aggregate needs but does not declare is NOT wiring (#22991)', + ); + // (t) the job-level `if:` on the hosting lane, read the way the live assertion above reads it. + { + const laneIfDoc = parse(aggregateFixture({ declare: true, need: true, laneIf: "github.event_name != 'pull_request'" })); + const laneHosts = jobsCarriedByLint(laneIfDoc).filter(([, job]) => pinSteps(job).length > 0); + assert( + laneHosts.length === 1 && Object.prototype.hasOwnProperty.call(laneHosts[0][1], 'if'), + 'wiring: a job-level `if:` on the lane hosting the pin is SEEN by the hosts read (#22991) — the live no-`if:` assertion depends on it', + ); + } // (n) the genuine article, spelled the way lint.yml spells it. assert( pinWiredIn(lintJobFixture(' - name: Required-context name pin', ` run: pnpm ${PIN_SCRIPT}`)), @@ -3239,14 +3328,14 @@ async function selfTest() { const wiring = pkg.scripts?.['check:required-contexts'] ?? ''; assert(/check-required-contexts\.mjs --self-test/.test(wiring), 'wiring: `check:required-contexts` must run this file\'s --self-test first'); assert(/check-required-contexts\.mjs(?! --self-test)/.test(wiring), 'wiring: `check:required-contexts` must also run the real pin, not only the self-test'); - // The pin lives in a job it also pins. That is deliberate and worth stating: - // renaming the gate-family job turns this gate red under the NEW name, while - // the old required context stops reporting — the PR is blocked from both - // sides. That is also why the #9325 rename could not be self-serve: the + // The pin lives under a context it also pins — in a lane of the `lint` aggregate since + // #22991. That is deliberate and worth stating: renaming the aggregate turns this gate red + // under the NEW name, while the old required context stops reporting — the PR is blocked + // from both sides. That is also why the #9325 rename could not be self-serve: the // repo-side half is checkable here, the Settings half is not reachable at all. assert( REQUIRED_CONTEXTS.some((entry) => entry.workflow === 'lint.yml' && entry.job === 'lint'), - 'wiring: the job this gate runs in is itself registered, so a rename of it cannot be the one rename nothing notices', + 'wiring: the aggregate this gate runs under is itself registered, so a rename of it cannot be the one rename nothing notices', ); // The instruction-surface scan must be wired into the PIN, not only into // this self-test — a scan only the self-test exercises is #4690's phantom diff --git a/scripts/pm/check-dispatch-gates.mjs b/scripts/pm/check-dispatch-gates.mjs index fb62ab8112f..a333797d3b0 100644 --- a/scripts/pm/check-dispatch-gates.mjs +++ b/scripts/pm/check-dispatch-gates.mjs @@ -64,18 +64,29 @@ * only decides which tier a run gets: * * a dev box, no flag `--self-test --fast` the fast tier, what a delivery runs - * `--slow` on argv `--self-test` both tiers, on demand - * `GITHUB_ACTIONS` is `true` `--self-test` both tiers, what CI runs - * - * The CI read is the one environment read this file makes, and its failure - * direction is the SAFE one: an unset variable where it should be set buys a - * shorter run that still prints which tier it was, never a quiet one; a set - * variable on a dev box buys the full battery, loud. `lint.yml` runs this gate - * with no argument, so the slow tier stays wired in CI without a workflow edit - * and without a second family for the derivation to name. The tier chosen and - * the reason are printed before the spawn, and the wall clock after it names - * the tier it measured. The fast tier fits a quiet box's foreground cap with - * room to spare; the detached form above stays the form for both tiers. + * `--slow` on argv `--self-test` both tiers, on demand (CI included) + * CI, a PR or a queue build `--self-test --fast` the fast tier (#22991) + * CI, any other event `--self-test` both tiers: `schedule`, `push` to main, a dispatch + * + * "CI" is `GITHUB_ACTIONS` set to `true`; the event is `GITHUB_EVENT_NAME`. + * The fast tier on CI is a CLOSED list of two events, `pull_request` and + * `merge_group` — the path every change waits on — under the #22991 ruling: + * that path runs the fast tier, the slow tier runs on the hourly `schedule` + * and on every `push` to main, so every case still runs at least daily + * (#22076's constraint) and a slow-tier red is a main red. Every other CI + * event keeps both tiers, `workflow_dispatch` included: the ruling moved the + * slow tier off those two events and no others, and a new trigger nobody + * thought about lands on the full battery rather than on the shorter one. + * + * Those two reads are the only environment reads this file makes, and their + * failure direction is the SAFE one: an unset variable where it should be set + * buys a shorter run on a dev box, or the full battery on CI, and either one + * prints which tier it was and why — never a quiet run. `lint.yml` runs this + * gate with no argument, so the tier follows the event without a workflow + * edit and without a second family for the derivation to name. The tier chosen + * and the reason are printed before the spawn, and the wall clock after it + * names the tier it measured. The fast tier fits a quiet box's foreground cap + * with room to spare; the detached form above stays the form for both tiers. * * ## The exit contract, and why the kill branch does not keep its old code * @@ -321,18 +332,45 @@ const SELF_TEST_CHILD_FLAG = '--self-test-child'; /** The flag that runs this file's own battery instead of the tool's. */ const SELF_TEST_FLAG = '--self-test'; -/** The flag that asks for both tiers off CI; without it a dev box runs the fast tier. */ +/** + * The flag that asks for both tiers wherever the event would pick the fast one — a dev box, + * or a CI run on one of FAST_TIER_EVENTS below; it is the manual override in both places. + */ const SLOW_TIER_FLAG = '--slow'; /** - * Which tier this run gets, and why — one read of argv and one of the + * The CI events that run the FAST tier (#22991): the PR and merge-queue path every change + * waits on. A closed list on purpose, so the failure direction stays the safe one — an event + * that is not on it (`schedule`, `push` to main, `workflow_dispatch`, a trigger added later) + * runs both tiers. The header's tier table argues the ruling this encodes. + */ +const FAST_TIER_EVENTS = Object.freeze(['pull_request', 'merge_group']); + +/** + * Which tier this run gets, and why — one read of argv and two of the * environment, decided here so the spawn below and its announcement cannot * disagree. Not exported: this file is a CLI, and the self-test drives the * decision through spawned children that echo the argv they were given. */ function tierOfThisRun({ argv = process.argv.slice(2), env = process.env } = {}) { if (argv.includes(SLOW_TIER_FLAG)) return { args: [], word: 'both tiers', why: `${SLOW_TIER_FLAG} on argv` }; - if (env.GITHUB_ACTIONS === 'true') return { args: [], word: 'both tiers', why: 'GITHUB_ACTIONS is true — the CI run owes the slow tier' }; + if (env.GITHUB_ACTIONS === 'true') { + const event = env.GITHUB_EVENT_NAME ?? ''; + if (FAST_TIER_EVENTS.includes(event)) { + return { + args: ['--fast'], + word: 'the fast tier', + why: + `GITHUB_EVENT_NAME is ${event} — the PR and merge-queue path runs the fast tier; ` + + `the slow tier runs on schedule and on push to main (#22991); ${SLOW_TIER_FLAG} runs both`, + }; + } + return { + args: [], + word: 'both tiers', + why: `GITHUB_EVENT_NAME is ${event === '' ? '(unset)' : event} — a CI run off the PR and merge-queue path owes the slow tier`, + }; + } return { args: ['--fast'], word: 'the fast tier', why: `the default off CI; ${SLOW_TIER_FLAG} runs both` }; } @@ -478,10 +516,13 @@ async function selfTest() { const redChild = stub('red.mjs', 'process.exit(1);\n'); const greenChild = stub('green.mjs', 'process.exit(0);\n'); + // Both CI variables are blanked unless a case sets them: this battery itself runs on CI, + // where the runner's own `GITHUB_EVENT_NAME` would otherwise leak into every case and decide + // the tier the cases below claim to be driving. const drive = (childPath, { extra = [], env = {} } = {}) => spawnSync(process.execPath, [SELF, SELF_TEST_CHILD_FLAG, childPath, ...extra], { encoding: 'utf8', - env: { ...process.env, GITHUB_ACTIONS: '', ...env }, + env: { ...process.env, GITHUB_ACTIONS: '', GITHUB_EVENT_NAME: '', ...env }, }); const killed = drive(killedChild); @@ -491,20 +532,42 @@ async function selfTest() { // The tier decision, driven against a child that echoes the argv it was given. const echoChild = stub('echo.mjs', "console.log(process.argv.slice(2).join(' '));\nprocess.exit(0);\n"); const offCi = drive(echoChild); - const onCi = drive(echoChild, { env: { GITHUB_ACTIONS: 'true' } }); const slowAsked = drive(echoChild, { extra: [SLOW_TIER_FLAG] }); + const onCi = (event, extra = []) => + drive(echoChild, { extra, env: { GITHUB_ACTIONS: 'true', GITHUB_EVENT_NAME: event } }); + const ranFast = (r) => + r.status === 0 && (r.stdout ?? '').trim() === '--self-test --fast' && r.stderr.includes('running the fast tier'); + const ranBoth = (r) => + r.status === 0 && (r.stdout ?? '').trim() === '--self-test' && r.stderr.includes('running both tiers'); + t('off CI with no flag the child runs the FAST tier — the argv it received says so', ranFast(offCi)); + // The #22991 event switch, one case per event lint.yml is triggered by, plus the two that + // decide its failure direction: an event nobody listed, and no event at all. ⛔ The fast + // events are spelled HERE, never read from FAST_TIER_EVENTS: a loop over the constant under + // test registers no case at all when the constant is emptied, and goes green over exactly + // the regression it exists to catch (measured: that ablation exited 0 before this line). + for (const event of ['pull_request', 'merge_group']) { + const r = onCi(event); + t( + `⭐ on CI a ${event} run gets the FAST tier, and says which event decided it`, + ranFast(r) && r.stderr.includes(`GITHUB_EVENT_NAME is ${event}`), + ); + } + for (const event of ['schedule', 'push']) { + const r = onCi(event); + t( + `⭐ on CI a ${event} run gets BOTH tiers — the slow tier's daily home under the #22991 ruling`, + ranBoth(r) && r.stderr.includes(`GITHUB_EVENT_NAME is ${event}`), + ); + } t( - 'off CI with no flag the child runs the FAST tier — the argv it received says so', - offCi.status === 0 && (offCi.stdout ?? '').trim() === '--self-test --fast' && offCi.stderr.includes('running the fast tier'), - ); - t( - `⭐ under GITHUB_ACTIONS=true the child runs BOTH tiers — the slow tier stays wired in CI without a workflow edit`, - onCi.status === 0 && (onCi.stdout ?? '').trim() === '--self-test' && onCi.stderr.includes('running both tiers'), - ); - t( - `…and ${SLOW_TIER_FLAG} asks for both tiers off CI, on demand`, - slowAsked.status === 0 && (slowAsked.stdout ?? '').trim() === '--self-test' && slowAsked.stderr.includes('running both tiers'), + '…and so does workflow_dispatch: the ruling moved the slow tier off the PR and queue path, and nothing else', + ranBoth(onCi('workflow_dispatch')), ); + t('…and an event the list never named runs BOTH tiers — the safe direction, loud', ranBoth(onCi('a_trigger_added_later'))); + const noEvent = onCi(''); + t('…and a CI run with no event at all runs BOTH tiers and says the event was unset', ranBoth(noEvent) && noEvent.stderr.includes('(unset)')); + t(`${SLOW_TIER_FLAG} on a pull_request run still asks for both tiers — the manual override reaches CI too`, ranBoth(onCi('pull_request', [SLOW_TIER_FLAG]))); + t(`…and ${SLOW_TIER_FLAG} asks for both tiers off CI, on demand`, ranBoth(slowAsked)); t( 'CONTROL: the killed stub really died by SIGNAL, so the branch under test is the one that ran', @@ -560,7 +623,7 @@ async function selfTest() { console.log( failures === 0 - ? '✓ check:pm-dispatch-gates --self-test: the exit contract holds in all three directions, and the tier decision in both.' + ? '✓ check:pm-dispatch-gates --self-test: the exit contract holds in all three directions, and the tier decision off CI and per CI event.' : `✗ check:pm-dispatch-gates --self-test: ${failures} case(s) failed.`, ); process.exit(failures === 0 ? 0 : 1);