diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70300f9f967..c6f374e89b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -912,11 +912,35 @@ jobs: # happens even when a leg failed, because a red suite is exactly when # the completeness guard earns its keep. # - # ⛔ A failing leg STOPS the remaining ones, the same way turbo stops - # scheduling on the first failure inside one run. Carrying on would add - # a second full suite to a job that is already red and already inside a - # `timeout-minutes` wall — turning an informative red into a killed job with no - # attestation at all, which is the #16173 failure mode itself. + # ⭐ ONE PACKAGE'S RED STOPS NO OTHER PACKAGE (#23191; #23177 ruling B, + # item A1, comment 6139629628). Both turbo invocations below pass + # `--continue=dependencies-successful`: a failed `test` task no longer + # cancels the tasks still queued beside it, and only a task whose own + # dependency failed is withheld, because it would test a broken build. + # A failed leg no longer skips the legs after it either. Turbo still + # exits non-zero when any task failed, `STATUS` keeps the FIRST + # non-zero exit, and the step exits with it, so the shard is red + # exactly when it was before — and now names every red package of the + # shard in one run instead of the first one turbo reached. + # + # Measured before this was written: a three-package probe (turbo + # 2.11.5, `a#test` exits 1, `c` depends on `a`) ran `b` and `c` to + # completion and exited 1 with `Failed: a#test`; without the flag the + # same run stopped at `a` (`Tasks: 1 successful, 4 total`), also exit + # 1. The flag moves no task hash (`--dry=json` on this tree, plain vs + # `--continue=dependencies-successful`: 5 of 5 identical), so the + # main-seeded cache above still replays. + # + # What it costs is time on a red shard, and the wall above already + # holds it. A red leg now runs as long as a green one, and the legs + # together are the shard's own planned work (each slice is placed at + # its plain weight), so a red shard takes what a full one takes — the + # shape of the slowest job the wall above was sized on (27m05s, every + # package executed and the CLI as all 3 slices). A leg + # the stall guard kills still lands inside p + s + 20m, the budget + # the wall was sized to, whichever leg it is: the legs after it are + # the rest of `s`, not a second suite on top of it. Before this, a red + # stopped the run early, so the hidden reds cost another CI cycle each. # # `test test:repo` (#16466): six heavy packages split their suite into # two turbo tasks -- `test` (package-local inputs) and `test:repo` @@ -938,7 +962,7 @@ jobs: if [ "$LEG" = __whole__ ]; then [ -n "$FILTERS" ] || continue LOG="$RUNNER_TEMP/test-core-packages.log" - set -- pnpm turbo run test test:repo $FILTERS --concurrency=4 --summarize --log-order=stream + set -- pnpm turbo run test test:repo $FILTERS --concurrency=4 --continue=dependencies-successful --summarize --log-order=stream else PKG="${LEG%%=*}" SLICE="${LEG#*=}" @@ -1009,11 +1033,14 @@ jobs: # is empty); measure-test-shard-timings.mjs resolves that digest # against the k/n the partitioner can emit and refuses one it # cannot, so a slice is never timed as the whole package. - set -- env "OS_TEST_SHARD=$SLICE" pnpm turbo run test "--filter=$PKG" --concurrency=4 --summarize --log-order=stream + set -- env "OS_TEST_SHARD=$SLICE" pnpm turbo run test "--filter=$PKG" --concurrency=4 --continue=dependencies-successful --summarize --log-order=stream fi LOGS="$LOGS $LOG" node scripts/run-with-stall-guard.mjs --log "$LOG" --stall-minutes 10 \ - --report-dir "$RUNNER_TEMP/stall-reports" -- "$@" || { STATUS=$?; break; } + --report-dir "$RUNNER_TEMP/stall-reports" -- "$@" || { + CODE=$? + if [ "$STATUS" -eq 0 ]; then STATUS=$CODE; fi + } done # Only over logs that EXIST, and via an explicit `if` rather than a diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 141acec6422..d37bbe683c3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -148,21 +148,44 @@ concurrency: # ───────────────────────────────────────────────────────────────────────────── # ───────────────────────────────────────────────────────────────────────────── -# ⭐ GATE ORDER IS DELIBERATE, not topical (#13690, maintainer ruling -# 2026-09-03): the `Lint & Repo Gates` job below runs its gates sequentially -# and aborts at the first non-zero exit, so the cheap, frequently-red gates run -# FIRST — `Docs anchors resolve to real headings` and `ADR anchors + number -# uniqueness` sit immediately after the six setup steps because they produced -# 78% of this job's reds, and running them there shrinks the unmeasured tail on -# 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. +# ⭐ EVERY GATE RUNS, AND ONE VERDICT STEP KEEPS THE RED (#23191; #23177 +# ruling B, item A1, comment 6139629628). Every gate step of a `Lint · *` lane +# and of a `Type Check · *` lane carries `continue-on-error: true` and an +# `id:`, so a red gate no longer skips the gates behind it: one run reports +# every red in the lane, where it used to report the first and leave the rest +# for the next CI cycle. The steps the gates depend on — checkout, Node and +# pnpm setup, the caches, the install, the gate-family selector and the +# workspace builds — carry neither and stay fail-fast: a gate run over a +# missing install or a half-built tree would only add reds about the runner. # -# 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`. +# `continue-on-error` alone trades a red for silence: the failed step's +# `conclusion` becomes `success` and so does the job's. What keeps the red is +# the LAST step of every lane, `Lane verdict` (`scripts/ci/lane-verdict.mjs`, +# whose header is the authority). It reads the lane's gate roster from this +# file — every step that sets `continue-on-error` — reads each gate's +# `outcome` from the `steps` context (the result BEFORE continue-on-error is +# applied; ⛔ never the `conclusion` after it), prints the red ones by name +# and exits 1. So a lane with a red gate still concludes `failure`, and the +# `Lint & Repo Gates` and `TypeScript Type Check` aggregates still go red. +# Adding a gate step: give it a unique `id:` and `continue-on-error: true`, +# like its neighbours. Without the `id:` the verdict cannot read it and reds +# the lane naming it; without the flag it is fail-fast again, and a red there +# hides every gate behind it once more — still red, one red per cycle. +# +# One gate feeds a later one: `check:authorable-surface` writes the gitignored +# json-schema tree that `check:docs` renders from (both in +# `typecheck-source-gates`). When the first cannot generate the tree, the +# second refuses on a missing or stale tree and says to generate it — a second +# red that names its cause, never a pass. +# +# The ORDER of the gates therefore no longer shortens a red run. The #13690 +# order (maintainer ruling 2026-09-03: the cheap, frequently-red +# `Docs anchors resolve to real headings` and `ADR anchors + number +# uniqueness` first, because they produced 78% of the single job's reds) is +# kept as it stands, at the top of `lint-repo-tooling`, because moving steps +# buys nothing — but ⛔ no gate is placed anywhere to shorten a red run any +# more: the verdict step is what keeps the red. A green run's wall clock was +# always the sum of its steps; a red run's now is too. # ───────────────────────────────────────────────────────────────────────────── # ⭐ WHERE A GATE LIVES, stated once, so that a step whose note is a one-line # `Rationale:` pointer to its script's header need not argue it again. No @@ -214,8 +237,9 @@ jobs: # 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: + # selector where the lane carries a scoped step, and, since #23191, the + # `Lane verdict` step that ends it (it replaced 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. @@ -224,8 +248,9 @@ jobs: # 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. + # runs its gates sequentially and, since #23191, runs every one of them + # whatever the gates before it said: the EVERY GATE RUNS 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 @@ -404,8 +429,7 @@ jobs: # 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. + # side. name: Lint · ESLint runs-on: ubuntu-latest # JOB WALL = CHECKOUT BUDGET (20, on the checkout step) + BODY BUDGET, the @@ -415,13 +439,6 @@ jobs: timeout-minutes: 30 permissions: contents: read - # ⭐ Read-only, and it buys exactly one thing: the LAST step of this job - # asks the jobs API how many of the steps below it never ran, because the - # size of that tail is the one fact no step can work out locally (the - # runner alone knows which step index it stopped at). See - # `scripts/report-unmeasured-gate-tail.mjs`. Nothing else in this job - # reads it, and no step writes anything anywhere. - actions: read steps: - name: Checkout repository @@ -471,6 +488,8 @@ jobs: # namespace imports (the dormant rule was never run in CI). Syntactic # only, so no build step needed. - name: ESLint + id: eslint + continue-on-error: true run: pnpm lint # Engine test-double contract gate (#4550, from #4434). A test double @@ -488,70 +507,37 @@ jobs: # 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 + id: engine-test-double-contract-gate + continue-on-error: true run: pnpm check:engine-double-contract - # ── 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. - - # 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() + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a + # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + # + # This is where the unmeasured-tail reporter pair stood, and its note + # said the fix for the gates a red skipped was not to run them, and ⛔ not + # `continue-on-error`, "which trades a red for silence". What holds now: + # the skipped gates DO run — every gate step above carries + # `continue-on-error` — and the trade is refused HERE: this step reads + # every gate's `outcome`, names the red ones and exits 1, so the job's + # conclusion is still red, never silent. The tail behind a red gate is + # empty by construction; behind a failed setup step this step lists the + # gates that never ran, from the `steps` context — final for every + # earlier step when this runs — rather than from the jobs API the + # reporter had to race (#18874), so no `actions: read` is needed any more. + - name: Lane verdict + if: ${{ !cancelled() }} 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 + OS_LANE_STEPS: ${{ toJSON(steps) }} + OS_LANE_JOB_STATUS: ${{ job.status }} + run: node scripts/ci/lane-verdict.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 + # to the two tenancy censuses: the docs and ADR anchors (still first; the + # EVERY GATE RUNS note above `jobs:` says why the order no longer + # matters), 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 @@ -563,9 +549,6 @@ jobs: 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 @@ -645,6 +628,8 @@ jobs: # 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 + id: docs-anchors-resolve-to-real-headings + continue-on-error: true run: | pnpm check:doc-anchors node scripts/check-system-context-census.mjs --self-test @@ -664,6 +649,8 @@ jobs: # 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) + id: adr-anchors-number-uniqueness-governed-code + continue-on-error: true run: pnpm check:adr-anchors # @objectstack/verify stand-in erasure guard (#6399). The third and @@ -688,15 +675,21 @@ jobs: # a green run over nothing. `pnpm lint` alone cannot tell "clean tree" # from "matched nothing". Runs its own --self-test first. - name: "@objectstack/verify stand-in erasure guard" + id: objectstack-verify-stand-in-erasure-guard + continue-on-error: true run: pnpm check:verify-stand-in # Rationale: `scripts/check-nul-bytes.mjs` header (#3127, #4890, #5157, #5460, # #6984, #5579, #5681). - name: Raw control-byte guard + id: raw-control-byte-guard + continue-on-error: true run: pnpm check:nul-bytes # Rationale: `scripts/check-gitlink-declared.mjs` header (#17472, #17468). - name: Staged gitlinks are declared in .gitmodules + id: staged-gitlinks-are-declared-in-gitmodules + continue-on-error: true run: pnpm check:gitlink-declared # The first three of these are the shared modules the two `scripts/**` @@ -779,6 +772,8 @@ jobs: # prints its own verdict whatever the other three do, and it does so early # in the job. - name: scripts/ shared-module self-tests (parse · entry predicate · comment mask · prerequisite frame) + id: scripts-shared-module-self-tests-parse-entry + continue-on-error: true run: | # Tolerate-and-collect (#10814) — see the note above this step. Each # self-test runs unconditionally and prints its own verdict; the step @@ -835,6 +830,8 @@ jobs: # Scans ~121 scripts/ files plus a read-only census of the rest, no # spawns; ~0.6s. - name: scripts/ TypeScript parses go through one module + id: scripts-typescript-parses-go-through-one + continue-on-error: true run: pnpm check:parse-guard # Nobody writes a NEW private comment-stripper (#12307). @@ -869,6 +866,8 @@ jobs: # GATE INVOCATION IDIOM note at the top of this file. # Scans ~5k files under packages/** + examples/**, no spawns; ~2s. - name: No new private comment-strippers (all route through js-comment-mask) + id: no-new-private-comment-strippers-all-route + continue-on-error: true run: | node scripts/check-comment-mask-adoption.mjs --self-test node scripts/check-comment-mask-adoption.mjs @@ -886,6 +885,8 @@ jobs: # Hermetic: real fixture files in a temp dir, no network, no node_modules # beyond `typescript`; ~1s. - name: Declaration references resolve (#19402) — self-test + id: declaration-references-resolve-self-test + continue-on-error: true run: node scripts/check-dts-references.mjs --self-test # A package's published `src/**` may only import workspace packages its own @@ -905,6 +906,8 @@ jobs: # GATE INVOCATION IDIOM note at the top of this file. # Scans ~2k non-test src files across 78 workspace packages, no spawns; ~1s. - name: Published src imports only declared workspace deps + id: published-src-imports-only-declared + continue-on-error: true run: | node scripts/check-undeclared-dep-imports.mjs --self-test node scripts/check-undeclared-dep-imports.mjs @@ -912,12 +915,16 @@ jobs: # Rationale: `scripts/check-keyed-text-bounds.mjs` header (#12147, #11374, #12058, # #12143). - name: Keyed text-family columns declare their bound (#12147) + id: keyed-text-family-columns-declare + continue-on-error: true run: | node scripts/check-keyed-text-bounds.mjs --self-test node scripts/check-keyed-text-bounds.mjs # Rationale: `scripts/check-bash32-floor.mjs` header (#12221). - name: shell scripts hold the bash 3.2 floor + id: shell-scripts-hold-the-bash-3-2-floor + continue-on-error: true run: pnpm check:bash32-floor # Stack-collection enumerations vs the schema (#6242). `stack.zod.ts` @@ -930,6 +937,8 @@ jobs: # a time before, always after a missing key silently dropped a collection. # A deviation is legal here only as a waiver row carrying its reason. - name: Stack-collection enumerations answerable to stack.zod.ts + id: stack-collection-enumerations + continue-on-error: true run: pnpm check:stack-collection-maps # The vendored HotCRM permissions artifact has NO non-test loader (#13419). @@ -949,6 +958,8 @@ jobs: # Invoked as `node` rather than `pnpm check:*`: see the GATE INVOCATION # IDIOM note at the top of this file. ~1s, no spawns. - name: 'The #13419 name-fold fixture has no non-test loader' + id: the-name-fold-fixture-has-no-non-test-loader + continue-on-error: true run: | node scripts/check-position-name-fold-loaders.mjs --self-test node scripts/check-position-name-fold-loaders.mjs @@ -963,6 +974,8 @@ jobs: # who hits it. The sixth member (`description`) is a resolver output, kept # as a ledger row that is checked from both ends rather than skipped. - name: Widget `options` census derived from the spec, not pinned + id: widget-options-census-derived-from-the-spec + continue-on-error: true run: pnpm check:widget-option-census # The sdui-parser LOCKSTEP invariant (#12719 / #13150). Two copies of the @@ -984,6 +997,8 @@ jobs: # to the parser subtree would go dormant on exactly the PR that moves the # pin. Cost is one TypeScript parse of seven small files, ~0.4 s. - name: sdui-parser stays in lockstep with objectui's copy + id: sdui-parser-stays-in-lockstep-with-objectui + continue-on-error: true run: pnpm check:sdui-lockstep # The checked-in SDUI public-tier manifest (#12924, ruled 2026-08-29: wire @@ -1002,6 +1017,8 @@ jobs: # artefact would go dormant on exactly the PR that moves the pin. # Direct invocation per the GATE INVOCATION IDIOM at the top of this file. - name: SDUI manifest is present, intact and fresh at the objectui pin + id: sdui-manifest-is-present-intact-and-fresh + continue-on-error: true run: node scripts/check-sdui-manifest.mjs --self-test && node scripts/check-sdui-manifest.mjs # pm-dispatch SKILL.md line ratchet (#7341 / #5925 item 7): the PM skill @@ -1013,6 +1030,8 @@ jobs: # only with a maintainer ruling quoted in the raising PR — is stated in # the script's header, which is authoritative. - name: PM skill line ratchet + id: pm-skill-line-ratchet + continue-on-error: true run: pnpm check:pm-skill-ratchet # pm-skill issue-ID lint (maintainer ruling 2026-08-12): operative @@ -1021,6 +1040,8 @@ jobs: # citations; rulings keep date + verbatim quote. The script header is # authoritative, including the one self-expiring legacy waiver. - name: PM skill issue-ID lint + id: pm-skill-issue-id-lint + continue-on-error: true run: pnpm check:pm-skill-id-lint # PM label description cap. GitHub hard-caps label descriptions at 100 @@ -1034,6 +1055,8 @@ jobs: # guard would red a label GitHub accepts. Unconditional, like # the other self-tested gates above. - name: PM label description cap + id: pm-label-description-cap + continue-on-error: true run: pnpm check:pm-label-desc-cap # Every ROOT_DIR_WATCH_HINTS declaration stays READABLE BY A TEXT SCANNER @@ -1054,6 +1077,8 @@ jobs: # form, which is exactly how the two earlier per-file pins failed. # Repo-wide sweep of authored JS/TS, no spawns; ~0.5s. - name: ROOT_DIR_WATCH_HINTS declarations are literals + id: root-dir-watch-hints-declarations + continue-on-error: true run: pnpm check:watch-hint-literal # ADR-0087 D4's per-release correctness gate (#17080). The REAL run needs @@ -1066,6 +1091,8 @@ jobs: # from/to version pointing at another release, and the two absences that # are legitimate. Fixture-only, no spawns; well under a second. - name: Per-release spec-changes gate self-test + id: per-release-spec-changes-gate-self-test + continue-on-error: true run: pnpm check:release-spec-changes # Part-of/closing-keyword guard self-test (#8476). The guard itself is a @@ -1086,6 +1113,8 @@ jobs: # `env:`), so unwiring the gate reddens here rather than going quiet. # Imports one sibling module, reads two files; ~0.1s. - name: Part-of closing-keyword guard self-test + id: part-of-closing-keyword-guard-self-test + continue-on-error: true run: pnpm check:partof-closing-keyword # Commit card-trailer refusal self-test. The check itself is a PRE-PUSH @@ -1101,6 +1130,8 @@ jobs: # deferral check it carried before), so unwiring it reddens here rather # than going quiet. Pure functions plus two file reads; ~0.1s. - name: Commit card-trailer pre-push refusal self-test + id: commit-card-trailer-pre-push-refusal + continue-on-error: true run: pnpm check:commit-card-trailers # Publish-smoke tarball pin-set self-test. The assertion it pins lives on @@ -1111,6 +1142,8 @@ jobs: # release run, not on the PR that opened it. Pure functions, synthetic # fixtures, no pnpm/workspace/network; ~0.05s. - name: Publish-smoke pin-set self-test + id: publish-smoke-pin-set-self-test + continue-on-error: true run: pnpm check:publish-smoke-pin # Single-claim path guard self-test (#9402). Same split as the step above @@ -1134,10 +1167,14 @@ jobs: # unwiring the gate reddens here rather than going quiet. # Dependency-free, reads two files; ~0.1s. - name: Single-claim path guard self-test + id: single-claim-path-guard-self-test + continue-on-error: true run: pnpm check:single-claim-paths # Rationale: `scripts/check-closing-target-claim.mjs` header (#15845). - name: Closing-target claim guard self-test + id: closing-target-claim-guard-self-test + continue-on-error: true run: pnpm check:closing-target-claim # PM half-state sweeper self-test (#8528). `scripts/pm/check-half-states.mjs` @@ -1200,6 +1237,8 @@ jobs: # constant from being read as a hint, or giving this gate its own file # like its neighbour, is a separate card and not a defect of this step. - name: PM half-state sweeper self-test + id: pm-half-state-sweeper-self-test + continue-on-error: true run: pnpm check:pm-half-states # Contract-review record recogniser self-test (#18042 · #18701). The @@ -1215,11 +1254,15 @@ jobs: # step) deleted that file with its retired `--pair` sweep — ⛔ not a gate # added beside it. - name: Contract-review record recogniser self-test + id: contract-review-record-recogniser-self-test + continue-on-error: true run: pnpm check:pm-record-recognisers # Rationale: `scripts/pm/check-prior-rulings.mjs` header (#17009). # ⛔ Self-test only: the live read is report-only and never becomes a gate. - name: Prior-ruling reader self-test + id: prior-ruling-reader-self-test + continue-on-error: true run: pnpm check:pm-prior-rulings # Stamped-post helper self-test (#17314) — the WRITE half of the rule that @@ -1266,6 +1309,8 @@ jobs: # are claims about separate processes and are measured on separate # processes, by their timestamps. - name: Write-pace throttle self-test + id: write-pace-throttle-self-test + continue-on-error: true run: pnpm check:pm-write-pace # The fleet identity and the two doors — same family, same split. The @@ -1279,23 +1324,35 @@ jobs: # bot as committer, no token in any remote or config file, a credential # helper reached from the environment alone). - name: Fleet token minter self-test + id: fleet-token-minter-self-test + continue-on-error: true run: pnpm check:pm-fleet-token - name: Issue-create door self-test + id: issue-create-door-self-test + continue-on-error: true run: pnpm check:pm-issue-create - name: with-fleet wrapper self-test + id: with-fleet-wrapper-self-test + continue-on-error: true run: pnpm check:pm-with-fleet - name: Stamped-post helper self-test + id: stamped-post-helper-self-test + continue-on-error: true run: pnpm check:pm-post-stamped # Rationale: `scripts/pm/label-write.mjs` header (#18085). - name: Label-write helper self-test + id: label-write-helper-self-test + continue-on-error: true run: pnpm check:pm-label-write # Rationale: `scripts/pm/close-cards.mjs` header (#19469). - name: Card-closure helper self-test + id: card-closure-helper-self-test + continue-on-error: true run: pnpm check:pm-close-cards # Fleet-write relay self-tests — the same family and the same split as @@ -1315,6 +1372,8 @@ jobs: # a merge, a review, a ref, contents, a workflow, a release or an # organization endpoint, and none issues a whole-set PUT. - name: Fleet-write payload validator self-test + id: fleet-write-payload-validator-self-test + continue-on-error: true run: pnpm check:pm-fleet-write-validate # The executor's self-test drives a fake platform through the sender gate @@ -1325,6 +1384,8 @@ jobs: # one summary row per request, and a known token that comes back out of # NO summary line, log line or error. - name: Fleet-write executor self-test + id: fleet-write-executor-self-test + continue-on-error: true run: pnpm check:pm-fleet-write-execute # The seat-side dispatcher's self-test pins the transport selector that @@ -1334,6 +1395,8 @@ jobs: # request id and waited to its conclusion under an injected clock, and # both ceilings answered UNCONFIRMED and never retried. - name: Fleet-write dispatcher self-test + id: fleet-write-dispatcher-self-test + continue-on-error: true run: pnpm check:pm-fleet-write-dispatch # Issue-transfer door self-test (#21295) — the same family and the same @@ -1362,10 +1425,14 @@ jobs: # `[alias-self-test-not-run] pnpm check:pm-issue-transfer`. The # stale-`finding` sweep step below is held the same way. - name: Issue-transfer door self-test + id: issue-transfer-door-self-test + continue-on-error: true run: pnpm check:pm-issue-transfer # Rationale: `scripts/pm/board-snapshot.mjs` header (#17390). - name: Board-snapshot archiver self-test + id: board-snapshot-archiver-self-test + continue-on-error: true run: pnpm check:pm-board-snapshot # Stale-`finding` sweep self-test (#16904) — same family and the same @@ -1401,6 +1468,8 @@ jobs: # that this file still aliases that definition rather than re-growing a # copy of it. - name: Stale-`finding` sweep self-test + id: stale-finding-sweep-self-test + continue-on-error: true run: pnpm check:pm-stale-finding # Changeset-deadline census self-test (#16850) — same family and the same @@ -1423,6 +1492,8 @@ jobs: # control) leaves a production run just as green, which is the shape # `check:self-test-wired` requires this step for. - name: Changeset-deadline census self-test + id: changeset-deadline-census-self-test + continue-on-error: true run: pnpm check:pm-changeset-deadline-census # Governed-merges audit self-test (#9495) — same family, same split as @@ -1444,6 +1515,8 @@ jobs: # than restating it: the copy that does not exist cannot go stale. # ⛔ Do not helpfully re-add the list here. - name: Governed-merges audit self-test + id: governed-merges-audit-self-test + continue-on-error: true run: pnpm check:pm-governed-merges # Governed-surface PROSE pin (#9525) — the other half of the step above. @@ -1463,6 +1536,8 @@ jobs: # staleness it catches arrives via an edit to the REGISTER, in a PR that # need not touch either prose file at all. - name: Governed-surface prose pin + id: governed-surface-prose-pin + continue-on-error: true run: pnpm check:pm-governed-prose # Settings deny-roster pin (#18281) — the same shape as the two steps @@ -1478,6 +1553,8 @@ jobs: # to keep in step — whose failure mode is a silent skip of the very # reconciliation this step is. - name: 'Settings deny-roster pin (#18281)' + id: settings-deny-roster-pin + continue-on-error: true run: pnpm check:pm-settings-deny-roster # Expected-skips roster self-test (#18308) — same family and the same @@ -1488,10 +1565,14 @@ jobs: # is the only instrument this roster's matching rules have, which is # exactly the family `check:self-test-wired` requires this step for. - name: Expected-skips roster self-test + id: expected-skips-roster-self-test + continue-on-error: true run: pnpm check:pm-expected-skips # Rationale: `scripts/pm/release-rehearsal-clone.mjs` header (#9555). - name: Release-rehearsal clone preflight self-test + id: release-rehearsal-clone-preflight-self-test + continue-on-error: true run: node scripts/pm/release-rehearsal-clone.mjs --self-test # Shallow-history guard self-tests (#9902). Three seat-run tools ask @@ -1566,6 +1647,8 @@ jobs: # `check:required-contexts` step — so this is a choice on the merits, # not a constraint. - name: Shallow-history guard self-tests + id: shallow-history-guard-self-tests + continue-on-error: true run: | # Tolerate-and-collect (#10814) — see the note above this step. Each # self-test runs unconditionally and prints its own verdict; the step @@ -1599,12 +1682,16 @@ jobs: # Rationale: `scripts/check-step-collectors.mjs` header (#10814). - name: Step-collector gate (self-tests that mask each other) + id: step-collector-gate-self-tests-that-mask + continue-on-error: true run: | node scripts/check-step-collectors.mjs --self-test node scripts/check-step-collectors.mjs # Rationale: `scripts/check-self-test-wired.mjs` header (#11150). - name: Self-test wiring gate + id: self-test-wiring-gate + continue-on-error: true run: | node scripts/check-self-test-wired.mjs --self-test node scripts/check-self-test-wired.mjs @@ -1657,6 +1744,8 @@ jobs: # this workflow alone, and dispatch-gates.mjs derives gate families from # either spelling. - name: PM ci-failure self-test + id: pm-ci-failure-self-test + continue-on-error: true run: node scripts/pm/ci-failure.mjs --self-test # os-regen-merge self-test (#12893). Mirrors the `Verify-lock entry-point @@ -1692,6 +1781,8 @@ jobs: # this card: two literal steps do not yet justify the machinery, and a third # is when to revisit it. - name: os-regen-merge self-test + id: os-regen-merge-self-test + continue-on-error: true run: bash scripts/pm/os-regen-merge.sh --self-test # Claude hook guard self-tests (#11514, objectstack half of @@ -1757,6 +1848,8 @@ jobs: # built tree, a generator) must say so there; this comment is not the # place to enumerate it. - name: Claude hook guard self-tests (worktree-first · stash ban) + id: claude-hook-guard-self-tests-worktree-first + continue-on-error: true run: | # Tolerate-and-collect (#10814) — see the note above this step. Each # matrix runs unconditionally and prints its own verdict; the step @@ -1803,10 +1896,14 @@ jobs: # while the examples (which ARE linted) stay clean. This fails on any bare # metadata literal for the 16 factory domains in a doc code block. - name: Doc/skill authoring guard + id: doc-skill-authoring-guard + continue-on-error: true run: pnpm check:doc-authoring # Rationale: `scripts/check-doc-frontmatter.mjs` header (#10493, #4690, #7484). - name: Docs frontmatter parses + id: docs-frontmatter-parses + continue-on-error: true run: | node scripts/check-doc-frontmatter.mjs --self-test node scripts/check-doc-frontmatter.mjs @@ -1838,6 +1935,8 @@ jobs: # INVOCATION IDIOM note at the top of this file. Filesystem read plus one # type-stripped import; sub-second. - name: Docs nav label stays out of the title surfaces + id: docs-nav-label-stays-out-of-the-title + continue-on-error: true run: | node scripts/check-docs-nav-label.mjs --self-test node scripts/check-docs-nav-label.mjs @@ -1890,6 +1989,8 @@ jobs: # corpus scale, it moves on any file added under those roots, and a comment # nothing checks is exactly where a stale number hides. - name: Tenant-audit census matches the tree + id: tenant-audit-census-matches-the-tree + continue-on-error: true run: | node scripts/check-tenant-audit-census.mjs --self-test node scripts/check-tenant-audit-census.mjs @@ -1897,25 +1998,20 @@ jobs: # Rationale: `scripts/check-platform-object-tenancy-census.mjs` header (#14957, # #15155). - name: Platform-object tenancy census matches the tree + id: platform-object-tenancy-census-matches + continue-on-error: true run: | node scripts/check-platform-object-tenancy-census.mjs --self-test node scripts/check-platform-object-tenancy-census.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 only on failure, always exits 0: `lint-eslint`'s copy says why. - - name: Report how many gates never ran - if: failure() + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a + # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + - name: Lane verdict + if: ${{ !cancelled() }} env: - GITHUB_TOKEN: ${{ github.token }} - # ⚠️ Must equal this step's `name:`; `lint-eslint`'s copy says why. - OS_TAIL_REPORT_STEP: Report how many gates never ran - run: node scripts/report-unmeasured-gate-tail.mjs + OS_LANE_STEPS: ${{ toJSON(steps) }} + OS_LANE_JOB_STATUS: ${{ job.status }} + run: node scripts/ci/lane-verdict.mjs lint-docs-code: # Lane 3 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the @@ -1932,9 +2028,6 @@ jobs: 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 @@ -1973,10 +2066,14 @@ jobs: # Rationale: `scripts/check-docs-single-h1.mjs` header (#12236, #12249, #12250, # #4690). - name: One `

` per docs page + id: one-h1-per-docs-page + continue-on-error: true run: pnpm check:docs-single-h1 # Rationale: `scripts/check-docs-redirects.mjs` header (#9014, #8948). - name: Docs redirect destinations resolve, and no chains + id: docs-redirect-destinations-resolve + continue-on-error: true run: pnpm check:docs-redirects # The docs site's `app/[lang]/` is a catch-all that matches ANY single @@ -2009,6 +2106,8 @@ jobs: # including the OG marker stripped of its dot -- and where the proxy # condition is observed flipping the catch-all requirement off. - name: Docs locale catch-all and OG card URL dot invariant + id: docs-locale-catch-all-and-og-card-url-dot + continue-on-error: true run: pnpm check:docs-locale-catch-all # #11050 route spellings taught in prose: every /api/v1 wire-path @@ -2038,6 +2137,8 @@ jobs: # frontmatter step above: see the GATE INVOCATION IDIOM note at the top of # this file. - name: Route spellings taught in prose match the ledgers (advisory) + id: route-spellings-taught-in-prose-match + continue-on-error: true run: | node scripts/check-doc-route-spelling.mjs --self-test node scripts/check-doc-route-spelling.mjs --advisory @@ -2045,6 +2146,8 @@ jobs: # Rationale: `scripts/check-react-page-adapter-contract.mjs` header (#10751, # #10288). - name: React pages honour the useAdapter() query and result contracts + id: react-pages-honour-the-useadapter-query + continue-on-error: true run: pnpm check:react-page-adapter-contract # #11576 page discovery vs ground truth: what makes something a SERVED @@ -2056,17 +2159,23 @@ jobs: # array must be declared `export const X: Page =` or via `definePage()`, # so a page can never again be served-but-uncovered. - name: Kernel-reaching pages are declared in a discoverable shape + id: kernel-reaching-pages-are-declared + continue-on-error: true run: pnpm check:page-declaration-shape # Rationale: `scripts/check-docs-section-name.mjs` header (#10830, #10709, #10579, # #10827). - name: Docs form-section examples carry a `name` + id: docs-form-section-examples-carry-a-name + continue-on-error: true run: | node scripts/check-docs-section-name.mjs --self-test node scripts/check-docs-section-name.mjs # Rationale: `scripts/check-published-readme-links.mjs` header (#9632). - name: Published-README links are followable off the docs site + id: published-readme-links-are-followable + continue-on-error: true run: pnpm check:published-readme-links # #9018 docs image tags: the same "example image tag N majors behind @@ -2104,6 +2213,8 @@ jobs: # that matches nothing. The self-test is where every limb is observed # failing, and where the X.Y.Z tag-table metavariable is observed excluded. - name: Docs image tags track packages/cli's version + id: docs-image-tags-track-packages-cli-version + continue-on-error: true run: pnpm check:docs-image-tag # #9064 — the self-test of the VERSION-TIME REWRITER that keeps the gate @@ -2127,6 +2238,8 @@ jobs: # so the change that breaks one breaks the other, and both reds should land # in the same place on the same PR. - name: Docs image-tag version-time rewriter self-test + id: docs-image-tag-version-time-rewriter + continue-on-error: true run: pnpm check:docs-image-tag-sync # #9348 — the same treatment for the OTHER version-time rewriter, argued @@ -2184,6 +2297,8 @@ jobs: # packages/create-objectstack/src/template-version-stamps.test.ts; two # harnesses asserting the same thing would be worse than one. - name: Template version-time rewriter self-test + id: template-version-time-rewriter-self-test + continue-on-error: true run: pnpm check:template-version-sync # #16485 — the BUILD-time generator beside the version-time one above, and @@ -2203,12 +2318,16 @@ jobs: # unparseable template, an empty templates directory), --check covers the # live tree. - name: Scaffold emission policy generated into the on-ramp + id: scaffold-emission-policy-generated + continue-on-error: true run: pnpm check:scaffold-emission-policy # Rationale: `scripts/sync-release-index-currency.mjs` header (#15332, #10232, # #11649). # ⛔ Self-test only; never `check-release-section-coverage`'s `--strict` arm. - name: Release-index currency version-time rewriter self-test + id: release-index-currency-version-time-rewriter + continue-on-error: true run: pnpm check:release-index-currency-sync # #4851: the docs-accuracy-audit workflow's default scope is a generated list @@ -2225,6 +2344,8 @@ jobs: # list is a docs rename, so a `packages/**` paths filter would blind the gate # to exactly its own failure mode. - name: Docs-audit scope is derived, not hand-kept + id: docs-audit-scope-is-derived-not-hand-kept + continue-on-error: true run: pnpm check:docs-audit-scope # ADR-0090 D3 vocabulary ratchet: "role" is reserved-forbidden in docs @@ -2232,17 +2353,23 @@ jobs: # boundary, ARIA samples, educational mentions); NEW occurrences fail. # Improvements ratchet the baseline down via --update. - name: Reserved-word ("role") docs ratchet + id: reserved-word-role-docs-ratchet + continue-on-error: true run: pnpm check:role-word # Rationale: `scripts/check-corpus-claim-drift.mjs` header (#13582, #13539, # #13745, #13532). - name: Teaching-corpus lexical anti-drift ratchet + id: teaching-corpus-lexical-anti-drift-ratchet + continue-on-error: true run: pnpm check:corpus-claim-drift # Rationale: `scripts/check-skill-identifier-liveness.mjs` header (#13678). # It lives in this file, unfiltered: its inputs are `skills/**` and `packages/**`, # and a paths filter on either would let the other drift unseen. - name: Published-skills identifier liveness gate (two legs) + id: published-skills-identifier-liveness-gate + continue-on-error: true run: pnpm check:skill-identifier-liveness # #11671: `os i18n extract --fill=default` fills GAPS only, so revising a @@ -2256,6 +2383,8 @@ jobs: # Existing drift is frozen in scripts/i18n-stale-fill-baseline.json with a # reason per entry; NEW drift fails. Improvements ratchet down via --update. - name: Stale translation fills (i18n) ratchet + id: stale-translation-fills-i18n-ratchet + continue-on-error: true run: pnpm check:i18n-stale-fill # #6319: content/docs/getting-started/quick-reference.mdx is the protocol @@ -2271,6 +2400,8 @@ jobs: # the count is a docs edit, so a packages/** paths filter would blind it to # its own failure mode. - name: Quick-reference section counts match their tables + id: quick-reference-section-counts-match + continue-on-error: true run: pnpm check:quick-reference-counts # #17388: three hand-written pages enumerate @objectstack/spec's importable @@ -2291,6 +2422,8 @@ jobs: # is a docs edit, so a packages/** paths filter would blind it to its own # failure mode. - name: Hand-written spec enumerations match the exports map + id: hand-written-spec-enumerations-match + continue-on-error: true run: pnpm check:docs-spec-enumerations # #11752: the "Overlay whitelist (shared-DB tenancy invariant)" table in @@ -2308,11 +2441,15 @@ jobs: # a docs edit, so a packages/** paths filter would blind it to its own # failure mode. - name: Overlay whitelist table matches the metadata type registry + id: overlay-whitelist-table-matches-the-metadata + continue-on-error: true run: pnpm check:overlay-whitelist-table # Rationale: `scripts/check-runtime-services-index.mjs` header (#9604, #9588, # #9630, #9684, #9629). - name: Runtime-services indexes enumerate the chapter's real pages + id: runtime-services-indexes-enumerate + continue-on-error: true run: pnpm check:runtime-services-index # #10738 generalises the gate above to every section landing page. The same @@ -2360,6 +2497,8 @@ jobs: # blind it to its own failure mode. Reads ~34 meta.json + index.mdx pairs, # no spawns, no build; ~0.1s. - name: Section landing indexes enumerate their meta.json pages + id: section-landing-indexes-enumerate-their-meta + continue-on-error: true run: | node scripts/check-section-landing-index.mjs --self-test node scripts/check-section-landing-index.mjs @@ -2408,6 +2547,8 @@ jobs: # Both existing collectors in this file carry self-tests ONLY, for the # same reason. Keep the sweep out of the collector block. - name: Symbol-anchor gate self-tests + id: symbol-anchor-gate-self-tests + continue-on-error: true run: | failed="" run_self_test() { @@ -2432,6 +2573,8 @@ jobs: fi - name: ADR symbol anchors resolve (no line numbers survive) + id: adr-symbol-anchors-resolve-no-line-numbers + continue-on-error: true run: node scripts/check-adr-symbol-anchors.mjs # The SECOND corpus (#15765), and it is a `defineCorpus` registration over @@ -2440,6 +2583,8 @@ jobs: # a production sweep inside the collector block re-invokes the stub the # harness plants and the count comes out 4/3. - name: scripts/** symbol anchors resolve (no line citations on tracked targets survive) + id: scripts-symbol-anchors-resolve-no-line + continue-on-error: true run: node scripts/check-scripts-symbol-anchors.mjs # The THIRD corpus (#17065) — `packages/spec/src/**` doc blocks, again a @@ -2454,6 +2599,8 @@ jobs: # Repairing those 7 edits `packages/spec/**` text, which is `domain:spec` # work with its own cards — the registration does not do it. - name: packages/spec/src/** doc-block symbol anchors resolve (no NEW line citations on tracked targets) + id: packages-spec-src-doc-block-symbol-anchors + continue-on-error: true run: node scripts/check-spec-docblock-symbol-anchors.mjs # NOTE: the standing platform test checklist (docs/qa/platform-checklist/) @@ -2486,6 +2633,8 @@ jobs: # INVOCATION IDIOM note at the top of this file. The `--self-test` leg runs # first because a checker whose own cases failed has no verdict worth printing. - name: Platform-checklist watchdog workflow pin + id: platform-checklist-watchdog-workflow-pin + continue-on-error: true run: | node scripts/check-platform-checklist-watchdog.mjs --self-test node scripts/check-platform-checklist-watchdog.mjs @@ -2498,6 +2647,8 @@ jobs: # occurrences today); tests, comments, skills/ and docs/ are excluded, and # driver-layer `execCtx.tenantId` is never matched. - name: Org-identifier authoring guard + id: org-identifier-authoring-guard + continue-on-error: true run: pnpm check:org-identifier # Authorization resolution must stay single-sourced (resolveAuthzContext, @@ -2505,6 +2656,8 @@ jobs: # security path (the REST-vs-dispatcher sys_user_role drift) and against an # entry point silently dropping the delegation. - name: Single authz resolver guard + id: single-authz-resolver-guard + continue-on-error: true run: pnpm check:authz-resolver # #11352. Product code may not read a test-RUNNER variable (`TEST`, @@ -2519,6 +2672,8 @@ jobs: # Dependency-free source scan over `**/src/**`, comments and literals # masked; runs its own --self-test first. - name: Runner-env posture guard + id: runner-env-posture-guard + continue-on-error: true run: pnpm check:runner-env-posture # #11341, the harness half of the same class the guard above closes in @@ -2552,6 +2707,8 @@ jobs: # because "exits non-zero" is the claim and no process can observe its own # exit status. Static AST over 82 sources; under a second, no build. - name: CLI test child-env guard + id: cli-test-child-env-guard + continue-on-error: true run: pnpm check:cli-test-child-env # #13940. A version stamp about a PINNED VENDOR is an attestation: it says a @@ -2572,6 +2729,8 @@ jobs: # its own population, so a scan that stopped matching would print a # confident green over a tree it never read. - name: Vendor version stamps + id: vendor-version-stamps + continue-on-error: true run: pnpm check:vendor-version-stamps # #12016. A CLI command id spelled as a string literal OUTSIDE the CLI @@ -2584,6 +2743,8 @@ jobs: # measured before the gate was written; the sibling sweep INSIDE # packages/cli is #11465 / #12177, and the two populations do not overlap. - name: CLI command-id literals resolve + id: cli-command-id-literals-resolve + continue-on-error: true run: pnpm check:cli-command-ids # #15393. `os package publish` ships its example set TWICE — the oclif @@ -2601,6 +2762,8 @@ jobs: # whitespace and ORDER — the two voices may differ, the invocations may not. # Reads two source files; no build, no install. - name: CLI examples match the documented block + id: cli-examples-match-the-documented-block + continue-on-error: true run: pnpm check:cli-examples-parity # #4093 follow-up. Discovery tells a consumer an absent capability is @@ -2611,6 +2774,8 @@ jobs: # CORE_SERVICE_PROVIDER is a real workspace package, and that no core # slot is missing an entry. - name: Service-provider remedy guard + id: service-provider-remedy-guard + continue-on-error: true run: pnpm check:service-providers # #3843 response-envelope guard. The route ledgers audit which routes EXIST; @@ -2626,6 +2791,8 @@ jobs: # absent from #3843's hand survey. An undeclared route module is an error, # never a default. Runs its own --self-test first. - name: Response-envelope guard + id: response-envelope-guard + continue-on-error: true run: pnpm check:route-envelope # Error-code casing guard (ADR-0112, #4003). The ledger's admission test @@ -2635,6 +2802,8 @@ jobs: # not parse its own response. That is how 208 of them accumulated across # 10 packages before batch 2. Runs its own --self-test first. - name: Error-code casing guard + id: error-code-casing-guard + continue-on-error: true run: pnpm check:error-code-casing # #8967: the docs publish an HTTP status per error code and the doors decide @@ -2650,6 +2819,8 @@ jobs: # `paths:` filter — either side of this reconciliation can move it, so filtering # on `packages/**` or on `content/**` would blind it to half its own subject. - name: Documented HTTP status matches the status the runtime emits + id: documented-http-status-matches-the-status + continue-on-error: true run: pnpm check:error-status-conformance # #8087: ADR-0112 closes `error.code`, and the dispatcher door did not @@ -2669,6 +2840,8 @@ jobs: # any package, and the registered vocabulary lives in packages/spec. # Runs its own --self-test first. - name: Dispatcher error-code vocabulary guard + id: dispatcher-error-code-vocabulary-guard + continue-on-error: true run: pnpm check:dispatcher-error-vocabulary # #13353: the PROVENANCE half of the same ledger. The gate above reports @@ -2686,10 +2859,14 @@ jobs: # green on exactly the PR that adds an unlisted stamper elsewhere. # Runs its own --self-test first (wired into the package script). - name: Error-code provenance guard + id: error-code-provenance-guard + continue-on-error: true run: pnpm --filter @objectstack/spec check:error-code-provenance # Rationale: `scripts/check-auth-mount-ledger.mjs` header (#10534, #9941, #10050). - name: Auth mount-vs-ledger guard + id: auth-mount-vs-ledger-guard + continue-on-error: true run: pnpm check:auth-mount-ledger # Vendor export contract (#16186). `@better-auth/core@1.7.3` deleted @@ -2708,6 +2885,8 @@ jobs: # a lockfile refresh and a new vendor import can each move it. Runs its # own --self-test first. - name: Vendor export contract + id: vendor-export-contract + continue-on-error: true run: pnpm check:vendor-export-contract # Optional-`error` sink contract (#9754, wired by #10608). A sink TYPE that @@ -2733,6 +2912,8 @@ jobs: # No `paths:` filter, for the standard reason: a filter on `packages/**` # would go dormant on the PR that edits the baseline. - name: Optional-`error` sink contract + id: optional-error-sink-contract + continue-on-error: true run: pnpm check:optional-error-sink # Namespace-wildcard fall-through guard (#4116). A handler mounted on @@ -2746,6 +2927,8 @@ jobs: # — and an undeclared mount is an error, never a default. Runs its own # --self-test first. - name: Wildcard fall-through guard + id: wildcard-fall-through-guard + continue-on-error: true run: pnpm check:wildcard-fallthrough # Raw `:type` route-param comparison guard (#6241). The `/meta/:type` @@ -2760,6 +2943,8 @@ jobs: # a textual scan would flag its own post-mortems. Zero exemptions today. # Runs its own --self-test first. - name: Normalized metadata-type guard + id: normalized-metadata-type-guard + continue-on-error: true run: pnpm check:meta-type-normalized # Filter-slot wire-alias parity guard (#8002). The ONE filter slot is @@ -2776,6 +2961,8 @@ jobs: # declaration is RED rather than an empty corpus reported as a pass # (#4690). Runs its own --self-test first. - name: Filter-slot wire-alias parity guard + id: filter-slot-wire-alias-parity-guard + continue-on-error: true run: pnpm check:filter-alias-parity # Init-service declaration guard (#4471, ADR-0116). The kernel's ordering @@ -2791,6 +2978,8 @@ jobs: # Declared tolerance stays in the plugin (optionalDependencies), never in a # checker-side ledger. Runs its own --self-test first. - name: Init-service declaration guard + id: init-service-declaration-guard + continue-on-error: true run: pnpm check:init-service-contract # Settings bind-window guard (#11045). The same argument as the gate above, @@ -2810,6 +2999,8 @@ jobs: # own --self-test first; that self-test is written so a depth-0-only walker # fails it, which is the failure mode #10250's census actually had. - name: Settings bind-window guard + id: settings-bind-window-guard + continue-on-error: true run: pnpm check:settings-bind-window # Durability-degradation log-level guard (#4632). #4460 raised ONE @@ -2826,11 +3017,15 @@ jobs: # a new durability seam, only stop known ones from regressing. Runs its own # --self-test first. - name: Durability-degradation log-level guard + id: durability-degradation-log-level-guard + continue-on-error: true run: pnpm check:durability-log-level # Rationale: `scripts/check-logger-receiver-detach.mjs` header (#12820, #12773, # #12792). - name: Logger receiver-detach guard + id: logger-receiver-detach-guard + continue-on-error: true run: pnpm check:logger-receiver-detach # Startup registry-verdict guard (#4777). One showcase cold start produced @@ -2849,6 +3044,8 @@ jobs: # cannot see #4769 at all (that "registry" is a database table). Runs its # own --self-test first. - name: Startup registry-verdict guard + id: startup-registry-verdict-guard + continue-on-error: true run: pnpm check:startup-registry-verdict # objectui pin-changeset guard (#4731). A frontend-only refresh reaches the @@ -2866,10 +3063,14 @@ jobs: # section — onto that same `classifyRange()` criterion, so both self-tests # run here: one criterion, two consumers, no room to drift apart. - name: objectui pin-changeset digest guard + id: objectui-pin-changeset-digest-guard + continue-on-error: true run: pnpm check:objectui-changeset # Rationale: `scripts/bump-objectui.selftest.sh` header (#10797). - name: objectui pin write-ordering guard + id: objectui-pin-write-ordering-guard + continue-on-error: true run: pnpm check:objectui-bump # Changeset-family gate self-tests (#6509). The SELF-TEST halves only — @@ -2912,23 +3113,18 @@ jobs: # repos in $TMPDIR and read two files (this workflow and # pr-automation.yml). ~0.8s + ~5.0s. - name: Changeset-family gate self-tests + id: changeset-family-gate-self-tests + continue-on-error: true 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 only on failure, always exits 0: `lint-eslint`'s copy says why. - - name: Report how many gates never ran - if: failure() + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a + # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + - name: Lane verdict + if: ${{ !cancelled() }} env: - GITHUB_TOKEN: ${{ github.token }} - # ⚠️ Must equal this step's `name:`; `lint-eslint`'s copy says why. - OS_TAIL_REPORT_STEP: Report how many gates never ran - run: node scripts/report-unmeasured-gate-tail.mjs + OS_LANE_STEPS: ${{ toJSON(steps) }} + OS_LANE_JOB_STATUS: ${{ job.status }} + run: node scripts/ci/lane-verdict.mjs lint-ci-engine: # Lane 4 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the @@ -2947,9 +3143,6 @@ jobs: 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 @@ -3018,6 +3211,8 @@ jobs: # self-tests (`lint-docs-code`) — # this runs its real check every time, unconditionally. - name: Lockstep package-count guard + id: lockstep-package-count-guard + continue-on-error: true run: pnpm check:lockstep-package-count # Route-ledger census guard (#16758). `packages/runtime/src/route-ledger.ts` @@ -3033,6 +3228,8 @@ jobs: # reds and a deliberate change shows its digits moving in the same diff. # Full-repo state, not diff-shaped, so it runs its real check unconditionally. - name: Route-ledger census guard + id: route-ledger-census-guard + continue-on-error: true run: pnpm check:route-ledger-census # Release-notes drift guard: the platform is one version-locked train, so @@ -3040,6 +3237,8 @@ jobs: # release page at content/docs/releases/v.mdx. Catches the gap that # let v10–v14 ship with no page while spec was already at 14.x. - name: Release-notes drift guard + id: release-notes-drift-guard + continue-on-error: true run: pnpm check:release-notes # Release-page STATUS guard (#8892). The guard above proves a released @@ -3052,6 +3251,8 @@ jobs: # describe a shipped release. Scoped to v16+ by maintainer ruling; the # gate prints that cutoff and its reason on both the OK and failure paths. - name: Release-page status guard + id: release-page-status-guard + continue-on-error: true run: pnpm check:release-page-status # Release SECTION-COVERAGE guard (#10268). The guard above is keyed on the @@ -3078,6 +3279,8 @@ jobs: # No `pnpm check:*` alias: see the GATE INVOCATION IDIOM note at the top # of this file. - name: Release section-coverage guard + id: release-section-coverage-guard + continue-on-error: true run: | node scripts/check-release-section-coverage.mjs --self-test node scripts/check-release-section-coverage.mjs @@ -3097,10 +3300,14 @@ jobs: # rest — @objectstack/spec keeps its release, which is where ADR-0087 D4's # spec-changes.json is attached. - name: Release-body limit guard + id: release-body-limit-guard + continue-on-error: true run: pnpm check:release-body # Rationale: `scripts/release-verify-npm.mjs` header (#15321). - name: Post-publish npm verification self-test + id: post-publish-npm-verification-self-test + continue-on-error: true run: node scripts/release-verify-npm.mjs --self-test # Release version-commit selection (ADR-0125 D1 as amended 2026-09-29). @@ -3117,6 +3324,8 @@ jobs: # Invoked as `node` rather than through a `pnpm check:*` alias: see the # GATE INVOCATION IDIOM note at the top of this file. - name: Release version-commit selection self-test + id: release-version-commit-selection-self-test + continue-on-error: true run: node scripts/release-pending-publish.mjs --self-test # #3825 Node-version drift guard: a runtime pin is 18 separate string @@ -3130,6 +3339,8 @@ jobs: # cases silently skipped (#3812). .nvmrc is now the single source of # truth, and this holds every workflow to it. - name: Node-version drift guard + id: node-version-drift-guard + continue-on-error: true run: pnpm check:node-version # pnpm-acquisition census. Sibling of the guard above, and here for the @@ -3154,11 +3365,15 @@ jobs: # for the pnpm store cache shells out to pnpm, so pnpm must be acquired # strictly earlier. Runs its own --self-test first. - name: pnpm-acquisition census + id: pnpm-acquisition-census + continue-on-error: true run: pnpm check:pnpm-acquisition # Rationale: `scripts/check-workflow-status-functions.mjs` header (#5343, #4900, # #4928, #4690). - name: Workflow status-function guard + id: workflow-status-function-guard + continue-on-error: true run: pnpm check:workflow-status-functions # Additive-label-write self-test (#10703). `pr-automation.yml` writes this @@ -3179,10 +3394,14 @@ jobs: # republishes it as `skipped`, which washes green -- see # check-required-contexts.mjs), so a red there blocks nothing. - name: Additive label-write self-test + id: additive-label-write-self-test + continue-on-error: true run: node scripts/pr-labels.mjs --self-test # Rationale: `scripts/check-whole-set-label-write.mjs` header (#10778, #10703). - name: No whole-set label PUT anywhere in the repo (#10778) + its self-test + id: no-whole-set-label-put-anywhere-in-the-repo + continue-on-error: true run: | node scripts/check-whole-set-label-write.mjs --self-test node scripts/check-whole-set-label-write.mjs @@ -3190,12 +3409,16 @@ jobs: # Rationale: `scripts/check-cross-repo-closer-outcome.mjs` header (#9595, #9575, # #9917). - name: Cross-repo closer outcome contract + id: cross-repo-closer-outcome-contract + continue-on-error: true run: | node scripts/check-cross-repo-closer-outcome.mjs --self-test node scripts/check-cross-repo-closer-outcome.mjs # Rationale: `scripts/check-closing-keyword-parity.mjs` header (#9755). - name: Closing-keyword parser parity + id: closing-keyword-parser-parity + continue-on-error: true run: | node scripts/check-closing-keyword-parity.mjs --self-test node scripts/check-closing-keyword-parity.mjs @@ -3240,12 +3463,16 @@ jobs: # reason as the step above: see the GATE INVOCATION IDIOM note at the top # of this file. - name: Merge-queue triage outcome contract + id: merge-queue-triage-outcome-contract + continue-on-error: true run: | node scripts/check-merge-queue-triage-outcome.mjs --self-test node scripts/check-merge-queue-triage-outcome.mjs # Rationale: `scripts/check-merged-branch-reaper-outcome.mjs` header (#13503). - name: Merged-branch reaper classifier contract + id: merged-branch-reaper-classifier-contract + continue-on-error: true run: | node scripts/check-merged-branch-reaper-outcome.mjs --self-test node scripts/check-merged-branch-reaper-outcome.mjs @@ -3268,6 +3495,8 @@ jobs: # dominance experiment lives, since a dev cannot fabricate a real # runner-starved CI run. - name: Shard attestation gate + id: shard-attestation-gate + continue-on-error: true run: pnpm check:shard-attestation # Test Core package selection self-test (#16453). ci.yml's "Compute this @@ -3282,10 +3511,14 @@ jobs: # the absence of the ones that must not fire (an empty docs-only merge # group selects nothing WITHOUT the #10057 complaint). Offline; ~2s. - name: Test Core package selection self-test + id: test-core-package-selection-self-test + continue-on-error: true run: pnpm check:select-shard-packages # Rationale: `scripts/ci/scheduled-full-run.mjs` header (#16467). - name: Hourly full run self-test and concurrency gate + id: hourly-full-run-self-test-and-concurrency + continue-on-error: true run: | node scripts/ci/scheduled-full-run.mjs --self-test node scripts/ci/scheduled-full-run.mjs --check-concurrency @@ -3303,21 +3536,40 @@ jobs: # scoped step, and that the scoped set equals the decided set. Offline; # ~3 s. - name: Gate-family selection self-test + id: gate-family-selection-self-test + continue-on-error: true run: pnpm check:select-gate-families # Rationale: `scripts/check-aggregator-roster.mjs` header (#10490, #22991, #4690). - name: Aggregator roster gate + id: aggregator-roster-gate + continue-on-error: true run: | node scripts/check-aggregator-roster.mjs --self-test node scripts/check-aggregator-roster.mjs + # Rationale: `scripts/ci/lane-verdict.mjs` header (#23191). The verdict + # every lane above and below ends with reads each gate's `outcome`, never + # its `conclusion`; on a green tree the two agree, so a verdict that read + # the wrong one would stay green on every run and miss every red. Only + # its self-test feeds it the shape that tells them apart (a failed gate + # whose conclusion is `success`). Offline fixtures; sub-second. + - name: Lane verdict self-test + id: lane-verdict-self-test + continue-on-error: true + run: node scripts/ci/lane-verdict.mjs --self-test + # Rationale: `scripts/check-required-contexts.mjs` header (#6865, #5617, #5584, # #4928, #9642). - name: Required-context name pin + id: required-context-name-pin + continue-on-error: true run: pnpm check:required-contexts # Rationale: `scripts/check-cross-package-test-inputs.mjs` header (#7802). - name: Cross-package test inputs + id: cross-package-test-inputs + continue-on-error: true run: pnpm check:cross-package-test-inputs # Console-intercept disarm (#10374). vitest's worker forwards every @@ -3338,6 +3590,8 @@ jobs: # that app proves the defect is still live in the installed vitest. # Self-test first, then the real scan; reads ~72 manifests; sub-second. - name: Console-intercept disarm + id: console-intercept-disarm + continue-on-error: true run: node scripts/check-console-intercept-disarm.mjs --self-test && node scripts/check-console-intercept-disarm.mjs # Declared registry log level (#15425, origin #13517). objectql's @@ -3355,6 +3609,8 @@ jobs: # declared would have shipped a known-incomplete list. Self-test first, # then the real scan; reads ~72 manifests and their test sources. - name: Declared registry log level + id: declared-registry-log-level + continue-on-error: true run: node scripts/check-registry-log-declared.mjs --self-test && node scripts/check-registry-log-declared.mjs # The same defect one package down (#15484). `packages/rest`'s own fault @@ -3368,6 +3624,8 @@ jobs: # seam does not recognise (it resolves to the DEFAULT, silently), and the # shipped default itself being lowered to quieten a log. Self-test first. - name: Declared REST fault-log level + id: declared-rest-fault-log-level + continue-on-error: true run: node scripts/check-rest-log-declared.mjs --self-test && node scripts/check-rest-log-declared.mjs # The pairing half of the one above (#17865, ruling batch #128 item 4). @@ -3381,10 +3639,14 @@ jobs: # check reached by a legitimate-looking config line. This gate names an # undeclared observer instead. Self-test first. - name: Declared REST fault-log level — observers + id: declared-rest-fault-log-level-observers + continue-on-error: true run: node scripts/check-rest-log-spy-declared.mjs --self-test && node scripts/check-rest-log-spy-declared.mjs # Rationale: `scripts/check-live-db-isolation.mjs` header (#10382, #9350). - name: Live-server database isolation + id: live-server-database-isolation + continue-on-error: true run: pnpm check:live-db-isolation # Nightly-tier file adoption (#16494). `scripts/nightly-tiers.mjs` moved @@ -3406,11 +3668,15 @@ jobs: # carries that boundary and the measurement behind the detector. # Reads ~80 manifests and a handful of configs; sub-second. - name: Nightly-tier file adoption + id: nightly-tier-file-adoption + continue-on-error: true run: pnpm check:tier-file-adoption # Rationale: `scripts/check-ci-filter-parity.mjs` header (#10379, #7802, #10015, # #9829). - name: CI filter parity (cross-package Layer C) + id: ci-filter-parity-cross-package-layer-c + continue-on-error: true run: | node scripts/check-ci-filter-parity.mjs --self-test node scripts/check-ci-filter-parity.mjs @@ -3431,6 +3697,8 @@ jobs: # reason as the release-rehearsal self-test above: see the GATE INVOCATION # IDIOM note at the top of this file. Pure functions, no IO, milliseconds. - name: Shard partitioner self-test + id: shard-partitioner-self-test + continue-on-error: true run: node scripts/partition-test-shards.mjs --self-test # A self-test that RUNS but cannot fail anything (#17097) — the sibling of @@ -3456,6 +3724,8 @@ jobs: # reason as the steps above: see the GATE INVOCATION IDIOM note at the top # of this file. In-process fixtures, no IO, milliseconds. - name: Test-timing parser self-test + id: test-timing-parser-self-test + continue-on-error: true run: node scripts/report-test-timings.mjs --self-test # The per-package suite-duration ratchet's own instrument (#16468). The @@ -3466,11 +3736,15 @@ jobs: # the GATE INVOCATION IDIOM reason at the top of this file. In-process # fixtures, no IO, milliseconds. - name: Suite-duration ceiling check self-test + id: suite-duration-ceiling-check-self-test + continue-on-error: true run: node scripts/check-test-suite-ceilings.mjs --self-test # Rationale: `scripts/check-declaration-mirrors.mjs` header (#10549, #10513, # #10450). - name: Hand-written declaration mirrors + id: hand-written-declaration-mirrors + continue-on-error: true run: | node scripts/check-declaration-mirrors.mjs --self-test node scripts/check-declaration-mirrors.mjs @@ -3492,6 +3766,8 @@ jobs: # never repairs. Invoked as `node` rather than a `pnpm check:*` alias for the # same reason as the step above (#9465). Reads two files; milliseconds. - name: Published list mirrors + id: published-list-mirrors + continue-on-error: true run: | node scripts/check-published-list-mirrors.mjs --self-test node scripts/check-published-list-mirrors.mjs @@ -3529,14 +3805,20 @@ jobs: # Inventory on demand: `pnpm examples:live-imports`. # Static scan of test sources; sub-second. - name: examples/** live-import inventory + id: examples-live-import-inventory + continue-on-error: true run: pnpm check:examples-live-imports # Rationale: `scripts/check-test-source-alias.mjs` header (#7668, #7849). - name: Test-source alias gate + id: test-source-alias-gate + continue-on-error: true run: pnpm check:test-source-alias # Rationale: `scripts/check-refd-timer-probe.mjs` header (#10785). - name: Ref'd-timer probe containment + id: ref-d-timer-probe-containment + continue-on-error: true run: pnpm check:refd-timer-probe # #4248 packaging-hygiene guard. Without a `files` whitelist npm packs the @@ -3553,10 +3835,14 @@ jobs: # hand-checked. Runs its own --self-test first: the pattern semantics can # be wrong while every package is right. - name: Published-files whitelist guard + id: published-files-whitelist-guard + continue-on-error: true run: pnpm check:published-files # Rationale: `scripts/check-manifest-repository-directory.mjs` header (#15991). - name: Manifest repository.directory guard + id: manifest-repository-directory-guard + continue-on-error: true run: pnpm check:manifest-repository-directory # WHERE-matcher conformance gate (#8494, from #7620). The read-side @@ -3577,6 +3863,8 @@ jobs: # fine, and a scan that quietly stops matching would report OK while # reading nothing (#4868's family). - name: WHERE-matcher conformance gate + id: where-matcher-conformance-gate + continue-on-error: true run: pnpm check:where-matcher # ObjectQL double `limit` gate (#11525, from #10978). The third member of @@ -3599,6 +3887,8 @@ jobs: # is fine, and a scan that quietly stops matching would report OK while # reading nothing (#4868's family). - name: ObjectQL double limit gate + id: objectql-double-limit-gate + continue-on-error: true run: pnpm check:objectql-double-limit # Paired kernel-hook pin gate (#5282, from #5170 / #5257 / #5274). The two @@ -3625,6 +3915,8 @@ jobs: # (#4868's family). Measured against main's corpus before being pinned # here: 4 dispatched hooks, 0 problems. - name: Paired kernel-hook pin gate + id: paired-kernel-hook-pin-gate + continue-on-error: true run: pnpm check:kernel-hook-pairs # Plugin teardown-shape gate (#10619, from #9371 / #10371). `Plugin` @@ -3664,6 +3956,8 @@ jobs: # Static AST over ~4,400 sources plus one `git show` and two spawns; # 3.4s + 2.5s measured locally, no build needed, so it belongs in this job. - name: Plugin teardown-shape gate + id: plugin-teardown-shape-gate + continue-on-error: true run: | node scripts/check-plugin-teardown-shape.mjs --self-test node scripts/check-plugin-teardown-shape.mjs @@ -3684,11 +3978,15 @@ jobs: # every descriptor is fine, and a scan that stops matching would report OK # while reading nothing (#4868's family). - name: Resume-authority declaration gate + id: resume-authority-declaration-gate + continue-on-error: true run: pnpm check:resume-authority-declared # Rationale: `scripts/check-driver-memory-census.mjs` header (#6664, #5704, #5499, # #5784, #6468, #6553, #4868). - name: driver-memory census gate + id: driver-memory-census-gate + continue-on-error: true run: pnpm check:driver-memory-census # Merge-driver wiring gate (#6008, from #4675 / #4868). `merge=os-regen` @@ -3716,6 +4014,8 @@ jobs: # (#4690's family: a gate archived as "runs elsewhere" that ran nowhere). # Pure git + fs, no build, seconds — so it belongs in this job. - name: Merge-driver wiring gate + id: merge-driver-wiring-gate + continue-on-error: true run: pnpm check:merge-driver # ADR-0122 type-alias convention gate (#5551). `packages/spec` spelled the @@ -3734,19 +4034,27 @@ jobs: # and a scan that stops matching would report OK while reading nothing # (#4868's family). - name: Spec type-alias convention gate (ADR-0122) + id: spec-type-alias-convention-gate-adr-0122 + continue-on-error: true run: pnpm check:spec-parsed-alias # Rationale: `scripts/check-tenant-chokepoint.mjs` header (#6792, #3724, #6577, # #2734, #3623, #4690). - name: Read-side tenant chokepoint gate + id: read-side-tenant-chokepoint-gate + continue-on-error: true run: pnpm check:tenant-chokepoint # Rationale: `scripts/pnpm-filter-targets.mjs` header (#10853, #4690). - name: Every committed pnpm --filter names a real package + id: every-committed-pnpm-filter-names-a-real + continue-on-error: true run: pnpm check:pnpm-filter-targets # Rationale: `scripts/check-agent-test-spelling.mjs` header (#10166, #11425). - name: No committed command forwards args to vitest through a bare -- + id: no-committed-command-forwards-args-to-vitest + continue-on-error: true run: pnpm check:agent-test-spelling # Every `#` override in turbo.json must name a package the @@ -3784,11 +4092,15 @@ jobs: # first: this gate's defect class is its matching rule, which a clean tree # cannot distinguish from a rule that stopped matching. - name: turbo.json package tasks name real packages and real scripts + id: turbo-json-package-tasks-name-real-packages + continue-on-error: true run: pnpm check:turbo-task-graph # Rationale: `scripts/check-workspace-manifest-cycles.mjs` header (#13513, # #14195). - name: workspace manifest dependency graph has no cycle + id: workspace-manifest-dependency-graph-has + continue-on-error: true run: pnpm check:workspace-manifest-cycles # Merged-result budget probe, wired by the card that wired the citation @@ -3802,6 +4114,8 @@ jobs: # self-test is the only instrument that can, and an instrument nobody runs # is silence. - name: Merged-result probe self-test + id: merged-result-probe-self-test + continue-on-error: true run: pnpm check:merged-result # Issue citations (#17512, wired by #18224). TWO commands, and the split @@ -3877,6 +4191,8 @@ jobs: # unresolvable base, which used to throw an unclassified exit code — # exits PREREQUISITE NOT MET rather than reporting a clean tree. - name: Issue citations this change adds resolve on the board + id: issue-citations-this-change-adds-resolve + continue-on-error: true env: # The board read is one GET per distinct number the change cites # (none at all when it cites none), against a PUBLIC board. Spelled @@ -3907,27 +4223,24 @@ jobs: # would defeat exactly that, so this step goes immediately above it # instead (#10572: no existing step's relative order moves). - name: 'Unquoted workflow step names do not silently truncate at " #" (#15149)' + id: unquoted-workflow-step-names-do-not-silently + continue-on-error: true run: pnpm check:workflow-step-name-quoting # Rationale: `packages/spec/scripts/check-duration-unit-keys.ts` header (#14478). - name: Duration-shaped spec keys carry their unit in the key name + id: duration-shaped-spec-keys-carry-their-unit + continue-on-error: true run: pnpm --filter @objectstack/spec check:duration-unit-keys - # ── 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 only on failure, always exits 0: `lint-eslint`'s copy says why. - - name: Report how many gates never ran - if: failure() + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a + # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + - name: Lane verdict + if: ${{ !cancelled() }} env: - GITHUB_TOKEN: ${{ github.token }} - # ⚠️ Must equal this step's `name:`; `lint-eslint`'s copy says why. - OS_TAIL_REPORT_STEP: Report how many gates never ran - run: node scripts/report-unmeasured-gate-tail.mjs + OS_LANE_STEPS: ${{ toJSON(steps) }} + OS_LANE_JOB_STATUS: ${{ job.status }} + run: node scripts/ci/lane-verdict.mjs lint-scoped-erasure: # Lane 5 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the @@ -3945,9 +4258,6 @@ jobs: 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 @@ -4066,6 +4376,8 @@ jobs: # 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 + id: engine-query-options-erasure-ratchet + continue-on-error: true if: steps.gate-families.outputs.query_options_erasure != 'skip' run: pnpm check:query-options-erasure @@ -4095,24 +4407,19 @@ jobs: # 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 + id: a-declared-gate-population-reaches-the-tree + continue-on-error: true 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 only on failure, always exits 0: `lint-eslint`'s copy says why. - - name: Report how many gates never ran - if: failure() + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a + # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + - name: Lane verdict + if: ${{ !cancelled() }} env: - GITHUB_TOKEN: ${{ github.token }} - # ⚠️ Must equal this step's `name:`; `lint-eslint`'s copy says why. - OS_TAIL_REPORT_STEP: Report how many gates never ran - run: node scripts/report-unmeasured-gate-tail.mjs + OS_LANE_STEPS: ${{ toJSON(steps) }} + OS_LANE_JOB_STATUS: ${{ job.status }} + run: node scripts/ci/lane-verdict.mjs lint-scoped-slot-corpus: # Lane 6 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the @@ -4128,9 +4435,6 @@ jobs: 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 @@ -4189,6 +4493,8 @@ jobs: # `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 + id: slot-lookup-ratchet + continue-on-error: true if: steps.gate-families.outputs.slot_lookup != 'skip' run: pnpm check:slot-lookup @@ -4219,6 +4525,8 @@ jobs: # 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 + id: scripts-entry-guards-go-through-one + continue-on-error: true if: steps.gate-families.outputs.entry_guard != 'skip' run: pnpm check:entry-guard @@ -4264,24 +4572,19 @@ jobs: # 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 + id: comment-mask-agrees-with-a-real-parser + continue-on-error: true 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 only on failure, always exits 0: `lint-eslint`'s copy says why. - - name: Report how many gates never ran - if: failure() + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a + # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + - name: Lane verdict + if: ${{ !cancelled() }} env: - GITHUB_TOKEN: ${{ github.token }} - # ⚠️ Must equal this step's `name:`; `lint-eslint`'s copy says why. - OS_TAIL_REPORT_STEP: Report how many gates never ran - run: node scripts/report-unmeasured-gate-tail.mjs + OS_LANE_STEPS: ${{ toJSON(steps) }} + OS_LANE_JOB_STATUS: ${{ job.status }} + run: node scripts/ci/lane-verdict.mjs lint-scoped-tooling: # Lane 7 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the @@ -4297,9 +4600,6 @@ jobs: 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 @@ -4376,6 +4676,8 @@ jobs: # 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 + id: pm-bare-root-worklist-self-test + continue-on-error: true if: steps.gate-families.outputs.bare_root_worklist != 'skip' run: node scripts/pm/bare-root-worklist.mjs --self-test @@ -4427,6 +4729,8 @@ jobs: # those skips it here and runs it on `push` to main and on the hourly # full run. - name: Self-test workflow-command gate + id: self-test-workflow-command-gate + continue-on-error: true if: steps.gate-families.outputs.self_test_workflow_commands != 'skip' run: | node scripts/check-self-test-workflow-commands.mjs --self-test @@ -4474,24 +4778,19 @@ jobs: # this job runs this step on every doubt, and `push` on main keeps it # unconditional. - name: Verify-lock entry-point self-test + id: verify-lock-entry-point-self-test + continue-on-error: true 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 only on failure, always exits 0: `lint-eslint`'s copy says why. - - name: Report how many gates never ran - if: failure() + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a + # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + - name: Lane verdict + if: ${{ !cancelled() }} env: - GITHUB_TOKEN: ${{ github.token }} - # ⚠️ Must equal this step's `name:`; `lint-eslint`'s copy says why. - OS_TAIL_REPORT_STEP: Report how many gates never ran - run: node scripts/report-unmeasured-gate-tail.mjs + OS_LANE_STEPS: ${{ toJSON(steps) }} + OS_LANE_JOB_STATUS: ${{ job.status }} + run: node scripts/ci/lane-verdict.mjs lint-pm-dispatch-gates: # Lane 8 of 8 behind `Lint & Repo Gates` (#22991; the `lint` job holds the @@ -4510,9 +4809,6 @@ jobs: 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 @@ -4642,24 +4938,19 @@ jobs: # 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 + id: pm-dispatch-gates-self-test + continue-on-error: true 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 - - # Runs only on failure, always exits 0: `lint-eslint`'s copy says why. - - name: Report how many gates never ran - if: failure() + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a + # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + - name: Lane verdict + if: ${{ !cancelled() }} env: - GITHUB_TOKEN: ${{ github.token }} - # ⚠️ Must equal this step's `name:`; `lint-eslint`'s copy says why. - OS_TAIL_REPORT_STEP: Report how many gates never ran - run: node scripts/report-unmeasured-gate-tail.mjs + OS_LANE_STEPS: ${{ toJSON(steps) }} + OS_LANE_JOB_STATUS: ${{ job.status }} + run: node scripts/ci/lane-verdict.mjs # Lane 1 of 4 behind the required `TypeScript Type Check` context. The # aggregator job at the bottom of this file explains the split, holds the @@ -4834,6 +5125,8 @@ jobs: # dist/*.d.ts and therefore lives after the build step, near the bottom of # this job ("Re-measure the type-check DEBT / TEST_DEBT ledger"). - name: Check every package is type-check covered or ledgered + id: check-every-package-is-type-check-covered-or + continue-on-error: true run: pnpm check:type-check-coverage # Conformance meta-gate (#4363): `spec/src/data/*-conformance.ts` holds the @@ -4847,11 +5140,15 @@ jobs: # CLASSIFIED direction so a NEW shared fixture cannot start life uncovered. # Reads source files only; no build, sub-second. - name: Check every driver runs the shared conformance cases + id: check-every-driver-runs-the-shared + continue-on-error: true run: pnpm check:driver-conformance # Rationale: `scripts/check-object-def-param-keys.mjs` header (#16711, #4311, # #16570). - name: Check object-definition parameters declare the keys they are read for + id: check-object-definition-parameters-declare + continue-on-error: true run: pnpm check:object-def-param-keys # Stall-guard self-test (#4250). scripts/run-with-stall-guard.mjs is what @@ -4871,10 +5168,14 @@ jobs: # healthy run still propagates its own exit status and steady output is # never called a stall. ~50s, no build, no network. - name: Stall-guard self-test + id: stall-guard-self-test + continue-on-error: true run: pnpm check:stall-guard # Rationale: `scripts/check-stall-guard-budget.mjs` header (#11916). - name: Stall-guard budget invariant + id: stall-guard-budget-invariant + continue-on-error: true run: pnpm check:stall-guard-budget # The `Rerun Safety` verdict table (#18590) — self-test half only, and the @@ -4895,11 +5196,15 @@ jobs: # only instrument watching them. Reads nothing but its own fixtures; no # build, no network, no token, sub-second. - name: Rerun-safety verdict table self-test + id: rerun-safety-verdict-table-self-test + continue-on-error: true run: pnpm check:rerun-safety-verdict # Rationale: `scripts/measure-stall-guard-headroom.mjs` header (#13246, #13121). # ⛔ `--self-test` only: never append a bare run (it needs a token). - name: Stall-guard headroom self-test (self-test half only, never the measurement) + id: stall-guard-headroom-self-test-self-test + continue-on-error: true run: pnpm check:stall-guard-headroom # The #12981 swallow census's own controls (#13919, maintainer ruling of @@ -4949,6 +5254,8 @@ jobs: # not a gate" banner. It reads `packages/**` source text through ts-parse; # no network, no token, no build. - name: Swallow-census control families (gated families only, never POSITIVE) + id: swallow-census-control-families-gated + continue-on-error: true run: pnpm check:swallow-census-controls # The dev-preflight gate's self-test — and deliberately only HALF of that @@ -4993,9 +5300,13 @@ jobs: # script would drag the scan half in with it — the same shape as the `dev` # chain calling this file directly (see the script's header). - name: Self-test the dev-prereqs gate (self-test half only, never the scan) + id: self-test-the-dev-prereqs-gate-self-test + continue-on-error: true run: node scripts/check-dev-prereqs.mjs --self-test - name: Type check (@objectstack/spec) + id: type-check-objectstack-spec + continue-on-error: true run: pnpm --filter @objectstack/spec exec tsc --noEmit # Meta-gate (#4203): the `check:generated` aggregate reconciles its @@ -5014,6 +5325,8 @@ jobs: # this job is unfiltered and required, so the meta-gate cannot go dormant. # Reads package.json only; no build, sub-second. - name: "Check every check:/gen: script is classified in the check:generated ledger" + id: check-every-check-gen-script-is-classified + continue-on-error: true run: pnpm --filter @objectstack/spec check:generated --reconcile-only # The last four artifact gates, moved here from ci.yml's `check-generated` @@ -5047,6 +5360,8 @@ jobs: # `check:authorable-surface` step below, whose `--check` mode writes the # gitignored json-schema/ tree and refuses to touch a tracked file (#4711). - name: Check skill docs are generated from SKILL.md frontmatter + id: check-skill-docs-are-generated-from-skill-md + continue-on-error: true run: pnpm --filter @objectstack/spec check:skill-docs # [#10096] The schema-free `/meta` URL-spelling data module @@ -5059,6 +5374,8 @@ jobs: # needs no build; belongs in this unfiltered required job for the same # reason as its artifact-gate neighbours. - name: Check the meta-url-spelling data module is current and spellings agree + id: check-the-meta-url-spelling-data-module + continue-on-error: true run: pnpm --filter @objectstack/spec check:meta-url-spelling # The two ADR-0087 D4 projections, under the #22449 B′ ruling. They are @@ -5069,9 +5386,13 @@ jobs: # request that only adds a registry entry needs no regeneration commit. # Like their neighbours they read source via tsx and need no build. - name: Check spec-changes.json generates from the ADR-0087 registries + id: check-spec-changes-json-generates + continue-on-error: true run: pnpm --filter @objectstack/spec check:spec-changes - name: Check the protocol upgrade guide generates from the ADR-0087 registries + id: check-the-protocol-upgrade-guide-generates + continue-on-error: true run: pnpm --filter @objectstack/spec check:upgrade-guide # The ruling's condition (1): the review value the committed copies carried @@ -5084,12 +5405,16 @@ jobs: # Red only when a side cannot be generated; a diff is never red. Measured # locally at ~6 s, no build needed. - name: Render the generated spec-changes and upgrade-guide diff against the base + id: render-the-generated-spec-changes + continue-on-error: true run: >- pnpm --filter @objectstack/spec exec tsx scripts/render-projection-diff.ts --base HEAD^1 --out-dir "$RUNNER_TEMP/spec-projections" --summary "$GITHUB_STEP_SUMMARY" # Written only when the diff is non-empty, so an unchanged run uploads nothing. - name: Upload the generated spec-projections diff + id: upload-the-generated-spec-projections-diff + continue-on-error: true uses: actions/upload-artifact@v7 with: name: spec-projections-diff @@ -5101,6 +5426,8 @@ jobs: # #4642). # Needs no build (reads `src/` via tsx), so it runs before the workspace build. - name: Check the export-origins baseline resolves as recorded + id: check-the-export-origins-baseline-resolves + continue-on-error: true run: pnpm --filter @objectstack/spec check:export-origins # The authorable KEY surface — what a metadata author may write, which for @@ -5119,6 +5446,8 @@ jobs: # refuses on a missing or stale tree, so a reordering fails loudly with the # prescription rather than reporting on last run's schemas. - name: Check the authorable key surface is recorded and nothing vanished + id: check-the-authorable-key-surface-is-recorded + continue-on-error: true run: pnpm --filter @objectstack/spec check:authorable-surface # Generated-docs gate: content/docs/references/** is generated from the spec @@ -5139,6 +5468,8 @@ jobs: # this step with that: the duplicate ~1600-schema generation, and the tracked # files a `check:` script had no business writing. - name: Check generated reference docs are in sync with the spec + id: check-generated-reference-docs-are-in-sync + continue-on-error: true run: pnpm --filter @objectstack/spec check:docs # Same class, same reasoning, different surface: skills/*/references/_index.md @@ -5161,10 +5492,14 @@ jobs: # that silent skip is what let the map keep pointing at data/dataset.zod.ts for a # year after #1620 renamed it to data/seed.zod.ts. - name: Check generated skill references are in sync with the spec + id: check-generated-skill-references-are-in-sync + continue-on-error: true run: pnpm --filter @objectstack/spec check:skill-refs # Rationale: `scripts/check-skill-frame-sync.mjs` header (#5130, #5451, #5799). - name: Check the decision frame is in sync across its four copies + id: check-the-decision-frame-is-in-sync-across + continue-on-error: true run: pnpm check:skill-frame-sync # The SECOND skill-frame gate — and deliberately only HALF of it (#8110). @@ -5201,11 +5536,15 @@ jobs: # `dev` chain calling check-dev-prereqs.mjs directly rather than through its # pnpm script (see that script's header). - name: Self-test the skill-frame freshness gate (self-test half only, never the scan) + id: self-test-the-skill-frame-freshness-gate + continue-on-error: true run: node scripts/check-skill-frame-freshness.mjs --self-test # Rationale: `scripts/check-skill-compatibility-version.mjs` header (#5331, #5245, # #4690). - name: Check SKILL.md compatibility declarations match the workspace majors + id: check-skill-md-compatibility-declarations + continue-on-error: true run: pnpm check:skill-compatibility # The fifth reader of skills/objectstack-platform/SKILL.md, and the first @@ -5224,10 +5563,14 @@ jobs: # so it cannot go dormant on the PR that adds a top-level key without # touching the skill — which is exactly how the four went missing. - name: Check the platform skill enumerates every authorable top-level stack key + id: check-the-platform-skill-enumerates + continue-on-error: true run: pnpm check:skill-top-level-keys # Rationale: `scripts/check-agent-model-declared.mjs` header (#6803, #6686). - name: Check every agent definition declares a model + id: check-every-agent-definition-declares + continue-on-error: true run: pnpm check:agent-model-declared # ADR-0049 tombstones, refusal strings and doc pages name the release that @@ -5266,20 +5609,28 @@ jobs: # The `--self-test` invocation is what `check:self-test-wired` requires; the pnpm # script runs it before the production pass. - name: Check no prose dates a change to an unreleased `@objectstack/spec` major + id: check-no-prose-dates-a-change + continue-on-error: true run: pnpm check:future-spec-major # Rationale: `scripts/check-ratchet-remedy-authority.mjs` header (#8435, #8517, # #8539, #8549, #8538). - name: Ratchet-remedy authority convention (#8435) across the gate farm + id: ratchet-remedy-authority-convention-across + continue-on-error: true run: pnpm check:ratchet-remedy-authority # Rationale: `scripts/check-skills-token-ratchet.mjs` header (#10473, #10402). - name: Published skills token ratchet (#10473) + its self-test + id: published-skills-token-ratchet-its-self-test + continue-on-error: true run: | node scripts/check-skills-token-ratchet.mjs --self-test node scripts/check-skills-token-ratchet.mjs - name: Check the react-blocks contract is in sync with the spec + id: check-the-react-blocks-contract-is-in-sync + continue-on-error: true run: pnpm --filter @objectstack/spec check:react-blocks # The shipped `objectstack.manifest.json` files declare TemplateManifestSchema @@ -5294,11 +5645,15 @@ jobs: # for the standard reason: a filter on packages/create-objectstack/** would go # dormant on exactly the PR that tightens the schema instead. - name: Check every shipped template manifest satisfies TemplateManifestSchema + id: check-every-shipped-template-manifest + continue-on-error: true run: pnpm --filter @objectstack/spec check:template-manifests # Rationale: `packages/spec/scripts/check-objectui-pin-citations.ts` header # (#10274, #9881, #9972, #9397, #10137, #5010). - name: Check spec pin citations still name the pin this repo builds against + id: check-spec-pin-citations-still-name-the-pin + continue-on-error: true run: pnpm --filter @objectstack/spec check:objectui-pin-citations # #11344. `packages/spec/llms.txt` is hand-kept, has no generator, and @@ -5316,8 +5671,19 @@ jobs: # every schema domain, so a PR that adds a package or a schema anywhere # can falsify this file without touching packages/spec/**. - name: Check the shipped llms.txt still describes this package + id: check-the-shipped-llms-txt-still-describes + continue-on-error: true run: pnpm --filter @objectstack/spec check:llms-txt + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a + # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + - name: Lane verdict + if: ${{ !cancelled() }} + env: + OS_LANE_STEPS: ${{ toJSON(steps) }} + OS_LANE_JOB_STATUS: ${{ job.status }} + run: node scripts/ci/lane-verdict.mjs + # Lane 2 of 4 behind the required `TypeScript Type Check` context. The # aggregator job at the bottom of this file explains the split, holds the # contract, and is the thing the merge queue actually requires — read it @@ -5446,6 +5812,8 @@ jobs: # against the real fumadocs/MDX pipeline) and on why this is a COMPARE rather # than a stamp. Runs its own --self-test first, through the alias. - name: CLI transcripts in content/docs match the registry they quote + id: cli-transcripts-in-content-docs-match + continue-on-error: true run: pnpm check:docs-transcript-drift # The per-package `typecheck` scripts the coverage gate above requires @@ -5479,6 +5847,8 @@ jobs: # `--filter` runs the whole workspace. Selection decides which packages # run; the package-local turbo cache still decides which execute. - name: Type check workspace packages + id: type-check-workspace-packages + continue-on-error: true env: OS_SHARD_EVENT_NAME: ${{ github.event_name }} OS_SHARD_PR_BASE_REF: ${{ github.event.pull_request.base.ref }} @@ -5556,6 +5926,15 @@ jobs: path: .turbo/cache key: ${{ runner.os }}-turbo-typecheck-${{ github.ref_name }}-${{ github.sha }} + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a + # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + - name: Lane verdict + if: ${{ !cancelled() }} + env: + OS_LANE_STEPS: ${{ toJSON(steps) }} + OS_LANE_JOB_STATUS: ${{ job.status }} + run: node scripts/ci/lane-verdict.mjs + # Lane 3 of 4 behind the required `TypeScript Type Check` context. The # aggregator job at the bottom of this file explains the split, holds the # contract, and is the thing the merge queue actually requires — read it @@ -5737,6 +6116,8 @@ jobs: # nothing is checking (#7668, #12078). No `dist/` anywhere is exit 3 # PREREQUISITE NOT MET, never a pass. - name: Sweep the built closure for vanished declarations + id: sweep-the-built-closure-for-vanished + continue-on-error: true run: pnpm check:dts-closure # ⚠️ `NODE_OPTIONS` here is HALF of the heap ceiling this gate runs tsc @@ -5764,10 +6145,21 @@ jobs: # reverse. The constant's comment carries the full reading and both # directions are pinned in that file's `--self-test`. - name: Re-measure the type-check DEBT / TEST_DEBT ledger + id: re-measure-the-type-check-debt-test-debt + continue-on-error: true env: NODE_OPTIONS: --max-old-space-size=6144 run: pnpm check:type-check-debt + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a + # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + - name: Lane verdict + if: ${{ !cancelled() }} + env: + OS_LANE_STEPS: ${{ toJSON(steps) }} + OS_LANE_JOB_STATUS: ${{ job.status }} + run: node scripts/ci/lane-verdict.mjs + # Lane 4 of 4 behind the required `TypeScript Type Check` context. The # aggregator job at the bottom of this file explains the split, holds the # contract, and is the thing the merge queue actually requires — read it @@ -5874,9 +6266,13 @@ jobs: # Same sweep as `typecheck-debt`'s step of this name (#15042, #7668, #12078). - name: Sweep the built closure for vanished declarations + id: sweep-the-built-closure-for-vanished + continue-on-error: true run: pnpm check:dts-closure - name: Type check example apps + id: type-check-example-apps + continue-on-error: true run: pnpm --filter './examples/*' run typecheck # Backward-compatibility gate: a frozen third-party-style consumer @@ -5884,6 +6280,8 @@ jobs: # spec change — a red typecheck here means the spec dropped/narrowed an # export a published-spec third party already uses. See the package README. - name: Type check downstream consumer contract + id: type-check-downstream-consumer-contract + continue-on-error: true run: pnpm --filter @objectstack/downstream-contract run typecheck # Public API-surface gate (#2035): the spec package IS the third-party API. @@ -5926,9 +6324,13 @@ jobs: # directions — a text scanner over prose fails by quietly matching # nothing, and only a positive control tells that apart from a clean tree. - name: Published-README export gate + id: published-readme-export-gate + continue-on-error: true run: pnpm check:published-readme-exports - name: Check @objectstack/spec public API surface + id: check-objectstack-spec-public-api-surface + continue-on-error: true run: pnpm --filter @objectstack/spec run check:api-surface # [#11350] Consumer-shaped declaration-emit pin against the BUILT root @@ -5945,6 +6347,8 @@ jobs: # the surface a consumer actually installs. Adds no required context — # a step in an existing lane (#9325). - name: Root-entry type nameability pin (built dist, declaration emit) + id: root-entry-type-nameability-pin-built-dist + continue-on-error: true env: OS_EXPECT_ROOT_NAMEABILITY: '1' run: pnpm --filter @objectstack/spec exec vitest run scripts/root-entry-type-nameability.pin.test.ts @@ -5969,6 +6373,8 @@ jobs: # above and refuses (never skips) on a stale or unbuilt tree. Adds no # required context: a step in an existing lane (#9325). - name: Check public-entry type nameability (built dist, declaration emit) + id: check-public-entry-type-nameability-built + continue-on-error: true run: pnpm --filter @objectstack/spec run check:entry-nameability # Same surface, the other axis: api-surface/ records that an export @@ -5982,6 +6388,8 @@ jobs: # with the other consumer gates. Self-tests first — a scan whose green # result is "nothing found" has to prove it can still find something. - name: Check no exported spec type resolves to `any` + id: check-no-exported-spec-type-resolves-to-any + continue-on-error: true run: pnpm --filter @objectstack/spec run check:exported-any # [#11927] The same question, asked of the SDK boundary instead of the @@ -6011,6 +6419,8 @@ jobs: # # Adds no required context — a step in an existing lane (#9325). - name: Check no exported client callable resolves to `any` + id: check-no-exported-client-callable-resolves + continue-on-error: true run: pnpm --filter @objectstack/client run check:exported-any-returns # Third axis on the same surface: api-surface/ shows a name on two @@ -6022,6 +6432,8 @@ jobs: # (dual-source-exports.baseline.json), so only a NEW one fails (#4446). # Self-tests first, like exported-any. - name: Check no new same-name dual-source spec exports + id: check-no-new-same-name-dual-source-spec + continue-on-error: true run: pnpm --filter @objectstack/spec run check:dual-source-exports # [#10199] The mechanized form of the 2026-08-20 ruling on #10096: @@ -6056,6 +6468,8 @@ jobs: # transitive walk that keeps the gate honest the day `splitting` is turned # on can only be exercised against fixtures. - name: Check declared browser-reachable spec entries link no zod + id: check-declared-browser-reachable-spec + continue-on-error: true run: pnpm --filter @objectstack/spec run check:browser-reachable-entries # Anti-drift for the skill EXAMPLES, not just the skill reference indexes @@ -6069,16 +6483,22 @@ jobs: # fails here instead of in a third party's editor. Reads the built dist, # so it runs after the build step alongside the other consumer gates. - name: Check skills TypeScript examples compile + id: check-skills-typescript-examples-compile + continue-on-error: true run: pnpm --filter @objectstack/spec run check:skill-examples # Rationale: `packages/lint/scripts/check-doc-formula-expressions.mjs` header # (#5116, #5026). - name: Check docs formula examples are valid CEL + id: check-docs-formula-examples-are-valid-cel + continue-on-error: true run: pnpm --filter @objectstack/lint run check:doc-formula-expressions # Rationale: `packages/lint/scripts/check-doc-security-posture.mjs` header # (#10618, #10581, #10714). - name: Check docs object examples pass the os validate security posture + id: check-docs-object-examples-pass-the-os + continue-on-error: true run: pnpm --filter @objectstack/lint run check:doc-security-posture # The YAML half of the same surface (#13086, ruled 2026-08-29). @@ -6097,6 +6517,8 @@ jobs: # cluster above, not a build dependency. Self-tests first, like its # neighbours. - name: Check docs YAML examples parse against live spec schemas + id: check-docs-yaml-examples-parse-against-live + continue-on-error: true run: pnpm --filter @objectstack/spec run check:yaml-examples # Same anti-drift class as the gates above, for the generated translation @@ -6124,6 +6546,8 @@ jobs: # Reads the built @objectstack/spec dist through the extract configs, so # it belongs after the build step with the other consumer gates. - name: Check generated translation bundles are in sync with the schema + id: check-generated-translation-bundles + continue-on-error: true run: pnpm check:i18n # Ratchet on the OTHER i18n question. The step above asks "are the @@ -6139,6 +6563,8 @@ jobs: # # Runs the built CLI over each config, so it also belongs after the build. - name: Check no new untranslated declared labels + id: check-no-new-untranslated-declared-labels + continue-on-error: true run: pnpm check:i18n-coverage # The i18n question none of its three neighbours can answer, because it @@ -6160,6 +6586,8 @@ jobs: # walker) rather than from source text, so it belongs here with the other # post-build consumer gates. - name: Check every declared translation group is walked by the extractor + id: check-every-declared-translation-group + continue-on-error: true run: pnpm check:i18n-walk-parity # The THIRD i18n question, and the one neither step above can answer @@ -6178,8 +6606,19 @@ jobs: # Imports the BUILT output of ten workspace packages, so it belongs here # with the other post-build consumer gates. - name: Check runtime-merged app navigation is translated in every locale + id: check-runtime-merged-app-navigation + continue-on-error: true run: pnpm check:app-nav-i18n + # ── THE LANE'S VERDICT (#23191): the last step, and the one that keeps a + # red gate red. The EVERY GATE RUNS note above `jobs:` argues it. ───── + - name: Lane verdict + if: ${{ !cancelled() }} + env: + OS_LANE_STEPS: ${{ toJSON(steps) }} + OS_LANE_JOB_STATUS: ${{ job.status }} + run: node scripts/ci/lane-verdict.mjs + # ──────────────────────────────────────────────────────────────────────── # The required context `TypeScript Type Check`, as an AGGREGATOR (#10471). # ──────────────────────────────────────────────────────────────────────── diff --git a/AGENTS.md b/AGENTS.md index 1f36e48e565..b1c1f158752 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -716,8 +716,8 @@ Root also exports: `defineStack`, `composeStacks`, `defineView`, `defineApp`, `d `packages/spec` has **six** checked-in generated artifacts, each with its own CI gate. All of them live in one job — `TypeScript Type Check` in `lint.yml`, which is required and has no paths filter, so no gate can go dormant on the PR that breaks it. -That job runs its gates **sequentially**, so the first stale artifact masks every one -behind it. Match the change to the gate and regenerate up front: +Its lanes run every gate even after an earlier one is red, so one run reports every stale +artifact, each still a CI round-trip. Match the change to the gate and regenerate up front: | You changed | Gate that fails | Regenerate with `pnpm --filter @objectstack/spec …` | |:---|:---|:---| diff --git a/scripts/ci/lane-verdict.mjs b/scripts/ci/lane-verdict.mjs new file mode 100644 index 00000000000..20bc99df70c --- /dev/null +++ b/scripts/ci/lane-verdict.mjs @@ -0,0 +1,592 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// lane-verdict -- the one step that keeps a gate lane's red (#23191). +// +// node scripts/ci/lane-verdict.mjs # in-job: the LAST step of a gate lane +// node scripts/ci/lane-verdict.mjs --self-test # offline, fixtures only, no env needed +// +// ## What this is for +// +// Every gate step of a `Lint · *` and a `Type Check · *` lane in +// `.github/workflows/lint.yml` runs with `continue-on-error: true` (#23177 +// ruling B, item A1, comment 6139629628), so a red gate no longer skips every +// gate behind it: one run reports every red in the lane instead of the first. +// +// `continue-on-error` on its own trades that red for silence. GitHub records +// two results per step -- `outcome`, before `continue-on-error` is applied, and +// `conclusion`, after it -- and for a failed step under the flag they DIFFER: +// `outcome` is `failure`, `conclusion` is `success`, and the job goes on to +// conclude `success`. This step is what puts the red back. It reads the lane's +// gate roster out of the workflow file, reads each gate's OUTCOME from the +// `steps` context, prints the red ones by name and exits 1, so the lane -- and +// the required aggregate that reads the lane's `result` -- still concludes +// `failure`. ⛔ Never read `conclusion` here: under the flag it is `success` for +// every gate, red or green, and the verdict would be green forever. +// +// ## Inputs, all from the step that runs it +// +// OS_LANE_STEPS `${{ toJSON(steps) }}` -- the `steps` context. It holds +// only steps that carry an `id:`, which is why every gate +// step carries one. +// OS_LANE_JOB_STATUS `${{ job.status }}` -- `success` unless a step WITHOUT +// `continue-on-error` failed (checkout, setup, install, +// the gate-family selector, a workspace build): those +// stay fail-fast, so their failure means the gates behind +// them never ran. +// GITHUB_JOB the job id (`lint-repo-tooling`, `typecheck-debt`, ...) +// GITHUB_WORKFLOW_REF `owner/repo/@`; the path names the workflow +// file in the checkout, which is the file the runner is +// executing (the checkout is the run's own commit). +// +// ## The roster is read from the WORKFLOW, never from the `steps` context +// +// The context only knows steps that have an id. A gate step added with +// `continue-on-error: true` and no `id:` would be absent from it, and a verdict +// that iterated the context would never see that gate -- its red would be the +// silence this file exists to remove. So the roster is every step of THIS job +// whose `continue-on-error` is set, read from the workflow, and a roster entry +// with no id, or with no outcome in the context, is red ("unread"), never a +// pass. A step without `continue-on-error` is not a gate here: its failure +// fails the job on its own, and `OS_LANE_JOB_STATUS` says so. +// +// ## Skipped is two different facts +// +// A gate the run never reached and a gate its own `if:` turned off (the +// gate-family selector's scoped steps) both read `skipped`. They are told +// apart by the two facts that are certain: whether the gate HAS an `if:`, and +// whether the job is still `success`. Only a gate with an `if:` in a job that +// is still `success` counts as turned off by its own condition; every other +// `skipped` is "did not run", which is red. +// +// ## Exit codes +// +// 0 every gate in the roster ran green or was turned off by its own `if:`, +// and no fail-fast step failed. +// 1 a FINDING: a gate is red, did not run, or cannot be read; or a fail-fast +// step failed (the job is red already; this names what it hid). +// 3 PREREQUISITE NOT MET -- an input above is absent or unparseable, so +// nothing was judged. On a runner that is still a red step, which is the +// point: a verdict that cannot read its lane has not passed it. Run +// locally, the bare form always lands here; `--self-test` is the local +// instrument. +// +// ## What it deliberately does NOT do +// +// * It changes no gate's command and judges nothing about the tree. A gate's +// own output is in its own step's log; this prints only which ones failed. +// * It does not call the jobs API. The `steps` context is final for every +// step before this one when this runs, which the API is not: the +// unmeasured-tail reporter this replaces had to race the API's own +// bookkeeping for the same facts (#18874). +// * It writes the job summary only when the verdict is not green, so a green +// lane's summary page stays as it was. + +import { appendFileSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import { EXIT_FINDINGS, EXIT_PREREQUISITE_NOT_MET, requireDependency } from '../import-prerequisite.mjs'; +import { isEntrypoint } from '../invoked-as.mjs'; + +const SCRIPT = 'lane-verdict'; +const REPO_ROOT = resolve(fileURLToPath(new URL('../..', import.meta.url))); + +/** A refusal: an input this verdict needs is absent or unreadable. */ +class LaneVerdictRefusal extends Error {} + +/** + * `continue-on-error` is set when it is anything but absent or false. An + * expression (`${{ … }}`) counts as set: whether it evaluates true decides the + * step's `conclusion`, never its `outcome`, and the outcome is what is read. + * + * @param {unknown} value + */ +export function continuesOnError(value) { + if (value === undefined || value === null || value === false) return false; + return String(value).trim().toLowerCase() !== 'false'; +} + +/** + * The workflow path out of `GITHUB_WORKFLOW_REF` (`owner/repo/@`). + * + * @param {string|undefined} ref + * @returns {string|null} + */ +export function workflowPathFromRef(ref) { + const m = /^[^/@\s]+\/[^/@\s]+\/([^@\s]+)@\S+$/.exec(String(ref ?? '').trim()); + return m ? m[1] : null; +} + +/** + * One job's steps, as the workflow declares them, from a parsed workflow + * document. Throws a refusal when the job or its step list is not there. + * + * @param {Record} doc the parsed workflow + * @param {string} jobId + * @returns {Array<{ position: number, name: string, id: string|null, cond: string|null, gate: boolean }>} + */ +export function laneSteps(doc, jobId) { + const job = doc?.jobs?.[jobId]; + if (!job || typeof job !== 'object') { + throw new LaneVerdictRefusal(`the workflow declares no job \`${jobId}\`, so there is no roster to read`); + } + if (!Array.isArray(job.steps) || job.steps.length === 0) { + throw new LaneVerdictRefusal(`job \`${jobId}\` declares no \`steps:\` list, so there is no roster to read`); + } + return job.steps.map((step, i) => { + const s = step && typeof step === 'object' ? step : {}; + const name = + typeof s.name === 'string' && s.name.trim() !== '' + ? s.name.trim() + : typeof s.uses === 'string' + ? `(unnamed step: uses ${s.uses})` + : `(unnamed step #${i + 1})`; + const id = typeof s.id === 'string' && s.id.trim() !== '' ? s.id.trim() : null; + const cond = s.if === undefined || s.if === null || String(s.if).trim() === '' ? null : String(s.if).trim(); + return { position: i + 1, name, id, cond, gate: continuesOnError(s['continue-on-error']) }; + }); +} + +/** + * The verdict, as a pure function of the roster, the `steps` context and the + * job status. Never throws: an unreadable input is a `refusal`, which is + * rendered and exits PREREQUISITE NOT MET. + * + * @param {{ steps: ReturnType, context: unknown, jobStatus: string|undefined }} input + */ +export function judgeLane({ steps, context, jobStatus }) { + const result = { + refusal: null, + gates: [], + red: [], + didNotRun: [], + unread: [], + skippedByCondition: [], + passed: [], + failFast: false, + jobStatus: String(jobStatus ?? ''), + ok: false, + }; + const status = result.jobStatus.trim(); + if (status === '') { + result.refusal = 'OS_LANE_JOB_STATUS is empty, so whether a fail-fast step failed cannot be read'; + return result; + } + if (!context || typeof context !== 'object' || Array.isArray(context)) { + result.refusal = 'OS_LANE_STEPS is not a JSON object, so no gate outcome can be read'; + return result; + } + const roster = (Array.isArray(steps) ? steps : []).filter((s) => s.gate); + if (roster.length === 0) { + result.refusal = + 'this job declares no `continue-on-error` gate step, so there is nothing to judge -- a verdict over zero ' + + 'gates is not a pass (#4690)'; + return result; + } + + result.failFast = status !== 'success'; + for (const step of roster) { + const entry = step.id === null ? undefined : context[step.id]; + let verdict; + let detail; + if (step.id === null) { + verdict = 'unread'; + detail = 'it sets continue-on-error but has no `id:`, so the steps context never carries its outcome'; + } else if (!entry || typeof entry !== 'object' || typeof entry.outcome !== 'string') { + verdict = 'unread'; + detail = `the steps context carries no outcome for id \`${step.id}\``; + } else if (entry.outcome === 'success') { + verdict = 'passed'; + } else if (entry.outcome === 'failure') { + verdict = 'red'; + detail = 'outcome `failure`'; + } else if (entry.outcome === 'cancelled') { + verdict = 'red'; + detail = 'outcome `cancelled`'; + } else if (entry.outcome === 'skipped' && step.cond !== null && !result.failFast) { + verdict = 'skippedByCondition'; + detail = `its own \`if:\` turned it off (${step.cond})`; + } else if (entry.outcome === 'skipped') { + verdict = 'didNotRun'; + detail = result.failFast + ? 'skipped behind a failed fail-fast step' + : 'skipped, and it carries no `if:` of its own that could have turned it off'; + } else { + verdict = 'unread'; + detail = `unrecognised outcome \`${entry.outcome}\``; + } + const row = { ...step, outcome: entry?.outcome ?? null, verdict, detail: detail ?? null }; + result.gates.push(row); + result[verdict].push(row); + } + result.ok = + !result.failFast && result.red.length === 0 && result.didNotRun.length === 0 && result.unread.length === 0; + return result; +} + +/** Escape a workflow-command message (the part after `::`). */ +export function escapeCommandData(text) { + return String(text).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); +} + +/** Escape a workflow-command property value (`title=…`). */ +export function escapeCommandProperty(text) { + return escapeCommandData(text).replace(/:/g, '%3A').replace(/,/g, '%2C'); +} + +/** + * The log lines, the annotations among them. Every gate is listed, green ones + * included, so the log alone answers "what ran in this lane". + * + * @param {ReturnType} v + * @param {string} jobId + * @returns {string[]} + */ +export function renderVerdict(v, jobId) { + const out = []; + if (v.refusal) { + out.push(`::error title=${escapeCommandProperty(`Lane verdict: ${jobId}`)}::${escapeCommandData( + `NOT MEASURED -- ${v.refusal}. A verdict that cannot read its lane has not passed it.`, + )}`); + out.push(`${SCRIPT}: ${jobId}: PREREQUISITE NOT MET -- ${v.refusal}.`); + return out; + } + const tag = { passed: 'ok ', red: 'RED ', didNotRun: 'NOT RUN ', unread: 'UNREAD ', skippedByCondition: 'skipped ' }; + for (const g of v.gates) out.push(` ${tag[g.verdict]} ${g.name}${g.detail ? ` -- ${g.detail}` : ''}`); + out.push(''); + if (v.failFast) { + out.push( + `::error title=${escapeCommandProperty(`Lane verdict: ${jobId}`)}::${escapeCommandData( + `a step without continue-on-error failed (job status \`${v.jobStatus}\`), so this lane is red already; ` + + `${v.didNotRun.length} gate(s) behind it never ran and are NOT MEASURED, not passed.`, + )}`, + ); + } + for (const g of v.red) { + out.push(`::error title=${escapeCommandProperty(`Red gate: ${g.name}`)}::${escapeCommandData( + `gate step "${g.name}" (id ${g.id}) failed -- its own log above says why. continue-on-error let the gates ` + + 'behind it run; this verdict keeps the lane red.', + )}`); + } + for (const g of v.unread) { + out.push(`::error title=${escapeCommandProperty(`Unread gate: ${g.name}`)}::${escapeCommandData( + `gate step "${g.name}" cannot be read: ${g.detail}. An unread gate is not a pass.`, + )}`); + } + if (!v.failFast) { + for (const g of v.didNotRun) { + out.push(`::error title=${escapeCommandProperty(`Gate did not run: ${g.name}`)}::${escapeCommandData( + `gate step "${g.name}" did not run: ${g.detail}.`, + )}`); + } + } + const counts = + `${v.gates.length} gate(s): ${v.passed.length} passed, ${v.red.length} red, ` + + `${v.didNotRun.length} did not run, ${v.unread.length} unread, ` + + `${v.skippedByCondition.length} turned off by their own if:`; + if (v.ok) { + out.push(`${SCRIPT}: ${jobId}: OK -- ${counts}.`); + } else { + const named = v.red.length > 0 ? ` Red: ${v.red.map((g) => g.name).join('; ')}.` : ''; + out.push(`${SCRIPT}: ${jobId}: FAILED -- ${counts}.${named}`); + } + return out; +} + +/** The same verdict as markdown, for the job summary; written only when not green. */ +export function renderSummary(v, jobId) { + if (v.refusal) return `## Lane verdict: \`${jobId}\`\n\n**NOT MEASURED** -- ${v.refusal}.\n`; + const rows = v.gates + .filter((g) => g.verdict !== 'passed') + .map((g) => `| ${g.verdict === 'skippedByCondition' ? 'skipped (own `if:`)' : g.verdict} | ${g.name.replace(/\|/g, '\\|')} |`); + return [ + `## Lane verdict: \`${jobId}\``, + '', + v.failFast + ? `A fail-fast step failed (job status \`${v.jobStatus}\`); the gates behind it never ran.` + : `${v.red.length} red gate(s) out of ${v.gates.length}; every gate ran, so this is a count, not a lower bound.`, + '', + '| verdict | gate |', + '|:--|:--|', + ...rows, + '', + ].join('\n'); +} + +async function loadWorkflowSteps(workflowPath, jobId) { + const { parse } = await requireDependency('yaml', () => import('yaml'), import.meta.url); + let text; + try { + text = readFileSync(join(REPO_ROOT, workflowPath), 'utf8'); + } catch (err) { + throw new LaneVerdictRefusal(`the workflow file \`${workflowPath}\` cannot be read (${err.code ?? err.message})`); + } + let doc; + try { + doc = parse(text); + } catch (err) { + throw new LaneVerdictRefusal(`the workflow file \`${workflowPath}\` does not parse (${err.message.split('\n')[0]})`); + } + return laneSteps(doc, jobId); +} + +async function main() { + const jobId = String(process.env.GITHUB_JOB ?? '').trim(); + const workflowPath = workflowPathFromRef(process.env.GITHUB_WORKFLOW_REF); + let v; + try { + if (jobId === '') throw new LaneVerdictRefusal('GITHUB_JOB is not set, so this is not a step of a job'); + if (workflowPath === null) { + throw new LaneVerdictRefusal('GITHUB_WORKFLOW_REF does not name a workflow file (`owner/repo/@`)'); + } + const raw = process.env.OS_LANE_STEPS; + if (raw === undefined || raw.trim() === '') { + throw new LaneVerdictRefusal('OS_LANE_STEPS is not set -- pass `${{ toJSON(steps) }}` from the step'); + } + let context; + try { + context = JSON.parse(raw); + } catch (err) { + throw new LaneVerdictRefusal(`OS_LANE_STEPS is not JSON (${err.message})`); + } + const steps = await loadWorkflowSteps(workflowPath, jobId); + v = judgeLane({ steps, context, jobStatus: process.env.OS_LANE_JOB_STATUS }); + } catch (err) { + if (!(err instanceof LaneVerdictRefusal)) throw err; + v = { refusal: err.message }; + } + + for (const line of renderVerdict(v, jobId || '(no job)')) console.log(line); + const summaryFile = process.env.GITHUB_STEP_SUMMARY; + if (!v.ok && summaryFile) { + try { + appendFileSync(summaryFile, `${renderSummary(v, jobId || '(no job)')}\n`); + } catch (err) { + console.log(`${SCRIPT}: the job summary could not be written (${err.code ?? err.message}); the log above stands.`); + } + } + if (v.refusal) process.exit(EXIT_PREREQUISITE_NOT_MET); + process.exit(v.ok ? 0 : EXIT_FINDINGS); +} + +// --------------------------------------------------------------------------- +// Self-test -- pins the red paths, the outcome-not-conclusion read first. +// --------------------------------------------------------------------------- + +// Set only after the verdict line prints; the dispatch refuses when it is unset +// (AGENTS.md, "Writing a --self-test": the handshake). +let selfTestReachedVerdict = false; + +// Battery name -> minimum case count. A literal, so a deleted battery names +// itself in the refusal instead of taking its own floor with it. +const SELF_TEST_BATTERIES = Object.freeze({ + 'green lanes': 2, + 'outcome, never conclusion': 3, + 'skipped: own condition vs did not run': 4, + 'fail-fast step failed': 2, + 'unread gates': 3, + 'refusals': 4, + 'roster from workflow text': 5, + 'workflow ref parsing': 3, + 'rendering': 5, +}); +const SELF_TEST_BATTERY_FLOOR = 9; + +async function selfTest() { + const { parse } = await requireDependency('yaml', () => import('yaml'), import.meta.url); + const seen = new Map(); + let current = null; + let failed = 0; + const battery = (name) => { + current = name; + }; + const t = (label, cond) => { + seen.set(current, (seen.get(current) ?? 0) + 1); + if (cond) { + console.log(` ok [${current}] ${label}`); + } else { + failed += 1; + console.log(` FAIL [${current}] ${label}`); + } + }; + + const WORKFLOW = [ + 'jobs:', + ' lane:', + ' steps:', + ' - name: Checkout repository', + ' uses: actions/checkout@v7', + ' - name: Install dependencies', + ' run: pnpm install --frozen-lockfile', + ' - name: Select the gate families this run pays for', + ' id: gate-families', + ' run: bash select.sh', + ' - name: Gate one', + ' id: gate-one', + ' continue-on-error: true', + ' run: node one.mjs', + ' - name: Gate two', + ' id: gate-two', + ' continue-on-error: true', + ' run: node two.mjs', + " - name: 'Scoped gate'", + ' id: scoped-gate', + " if: steps.gate-families.outputs.x != 'skip'", + ' continue-on-error: true', + ' run: node scoped.mjs', + ' - name: Lane verdict', + ' if: ${{ !cancelled() }}', + ' run: node scripts/ci/lane-verdict.mjs', + ].join('\n'); + const steps = laneSteps(parse(WORKFLOW), 'lane'); + const ctx = (one, two, scoped, extra = {}) => ({ + 'gate-families': { outcome: 'success', conclusion: 'success', outputs: {} }, + 'gate-one': { outcome: one, conclusion: one === 'failure' ? 'success' : one, outputs: {} }, + 'gate-two': { outcome: two, conclusion: two === 'failure' ? 'success' : two, outputs: {} }, + 'scoped-gate': { outcome: scoped, conclusion: scoped === 'failure' ? 'success' : scoped, outputs: {} }, + ...extra, + }); + + battery('green lanes'); + { + const v = judgeLane({ steps, context: ctx('success', 'success', 'success'), jobStatus: 'success' }); + t('every gate success -> ok', v.ok && v.passed.length === 3 && v.gates.length === 3); + const w = judgeLane({ steps, context: ctx('success', 'success', 'skipped'), jobStatus: 'success' }); + t('a scoped gate its own if: turned off -> still ok', w.ok && w.skippedByCondition.length === 1); + } + + battery('outcome, never conclusion'); + { + // The shape GitHub records for a failed step under continue-on-error: + // outcome failure, conclusion success. Reading `conclusion` would pass it. + const v = judgeLane({ steps, context: ctx('failure', 'success', 'success'), jobStatus: 'success' }); + t('outcome failure under conclusion success -> RED', !v.ok && v.red.length === 1 && v.red[0].id === 'gate-one'); + const both = judgeLane({ steps, context: ctx('failure', 'failure', 'success'), jobStatus: 'success' }); + t('two red gates -> both named, in workflow order', both.red.map((g) => g.name).join('|') === 'Gate one|Gate two'); + const cancelled = judgeLane({ steps, context: ctx('success', 'cancelled', 'success'), jobStatus: 'success' }); + t('outcome cancelled -> RED', !cancelled.ok && cancelled.red.length === 1); + } + + battery('skipped: own condition vs did not run'); + { + const unconditional = judgeLane({ steps, context: ctx('skipped', 'success', 'success'), jobStatus: 'success' }); + t('a gate with no if: that skipped -> did not run, RED', !unconditional.ok && unconditional.didNotRun.length === 1); + const behind = judgeLane({ steps, context: ctx('skipped', 'skipped', 'skipped'), jobStatus: 'failure' }); + t('every gate skipped behind a failed fail-fast step -> all did not run', behind.didNotRun.length === 3); + t('...and a scoped gate there is NOT credited to its own condition', behind.skippedByCondition.length === 0); + const scoped = judgeLane({ steps, context: ctx('success', 'success', 'skipped'), jobStatus: 'success' }); + t('a scoped gate skipped in a healthy job -> its own condition', scoped.skippedByCondition[0]?.id === 'scoped-gate'); + } + + battery('fail-fast step failed'); + { + const v = judgeLane({ steps, context: ctx('success', 'success', 'success'), jobStatus: 'failure' }); + t('every gate green but job status failure -> RED (fail-fast)', !v.ok && v.failFast); + const w = judgeLane({ steps, context: ctx('success', 'success', 'success'), jobStatus: 'cancelled' }); + t('job status cancelled -> RED', !w.ok && w.failFast); + } + + battery('unread gates'); + { + const noId = laneSteps( + parse('jobs:\n lane:\n steps:\n - name: Silent gate\n continue-on-error: true\n run: x\n'), + 'lane', + ); + const v = judgeLane({ steps: noId, context: {}, jobStatus: 'success' }); + t('a continue-on-error step with no id -> UNREAD, RED', !v.ok && v.unread.length === 1 && v.unread[0].name === 'Silent gate'); + const missing = judgeLane({ steps, context: { 'gate-one': { outcome: 'success' } }, jobStatus: 'success' }); + t('ids absent from the steps context -> UNREAD, RED', !missing.ok && missing.unread.length === 2); + const odd = judgeLane({ steps, context: ctx('success', 'neutral', 'success'), jobStatus: 'success' }); + t('an unrecognised outcome -> UNREAD, RED', !odd.ok && odd.unread.length === 1); + } + + battery('refusals'); + { + const none = laneSteps(parse('jobs:\n lane:\n steps:\n - name: A\n run: x\n'), 'lane'); + t('zero gates -> refusal, never a pass', judgeLane({ steps: none, context: {}, jobStatus: 'success' }).refusal !== null); + t('context not an object -> refusal', judgeLane({ steps, context: [], jobStatus: 'success' }).refusal !== null); + t('empty job status -> refusal', judgeLane({ steps, context: ctx('success', 'success', 'success'), jobStatus: '' }).refusal !== null); + let threw = null; + try { + laneSteps(parse(WORKFLOW), 'no-such-job'); + } catch (err) { + threw = err; + } + t('a job the workflow does not declare -> refusal', threw instanceof LaneVerdictRefusal); + } + + battery('roster from workflow text'); + { + t('only continue-on-error steps are gates', steps.filter((s) => s.gate).map((s) => s.id).join(',') === 'gate-one,gate-two,scoped-gate'); + t('a fail-fast step with an id is not a gate', steps.find((s) => s.id === 'gate-families')?.gate === false); + t('a quoted name is read unquoted', steps.some((s) => s.name === 'Scoped gate')); + t('the if: of a scoped gate is read', steps.find((s) => s.id === 'scoped-gate')?.cond === "steps.gate-families.outputs.x != 'skip'"); + t( + 'continue-on-error: false and an absent key are not gates; an expression is', + continuesOnError(false) === false && continuesOnError(undefined) === false && + continuesOnError('false') === false && continuesOnError(true) === true && continuesOnError('${{ inputs.soft }}') === true, + ); + } + + battery('workflow ref parsing'); + { + t('a pull-request ref', workflowPathFromRef('objectstack-ai/objectstack/.github/workflows/lint.yml@refs/pull/1/merge') === '.github/workflows/lint.yml'); + t('a branch ref', workflowPathFromRef('o/r/.github/workflows/ci.yml@refs/heads/main') === '.github/workflows/ci.yml'); + t('no ref -> null', workflowPathFromRef('') === null && workflowPathFromRef(undefined) === null); + } + + battery('rendering'); + { + const v = judgeLane({ steps, context: ctx('failure', 'success', 'success'), jobStatus: 'success' }); + const text = renderVerdict(v, 'lane').join('\n'); + t('a red gate is named in an ::error:: annotation', /::error title=Red gate%3A Gate one::/.test(text)); + t('the final line says FAILED and names the red gate', /lane-verdict: lane: FAILED .* Red: Gate one\./.test(text)); + const green = renderVerdict(judgeLane({ steps, context: ctx('success', 'success', 'success'), jobStatus: 'success' }), 'lane'); + t('a green lane prints OK and no annotation', /lane-verdict: lane: OK/.test(green.at(-1)) && !green.some((l) => l.startsWith('::'))); + t('a refusal renders as NOT MEASURED', /NOT MEASURED/.test(renderVerdict({ refusal: 'x' }, 'lane').join('\n'))); + t('annotation escaping', escapeCommandProperty('a:b,c%\n') === 'a%3Ab%2Cc%25%0A'); + } + + // ── The floor: every declared battery ran at least its pinned cases ───── + const declared = Object.keys(SELF_TEST_BATTERIES); + const floor = []; + if (declared.length < SELF_TEST_BATTERY_FLOOR) { + floor.push(`SELF_TEST_BATTERIES declares ${declared.length} batteries, below the pinned ${SELF_TEST_BATTERY_FLOOR}`); + } + for (const name of seen.keys()) { + if (!declared.includes(name)) floor.push(`battery "${name}" registered cases but is not declared`); + } + for (const name of declared) { + const count = seen.get(name) ?? 0; + if (count < SELF_TEST_BATTERIES[name]) { + floor.push(`battery "${name}" registered ${count} case(s), below its pinned ${SELF_TEST_BATTERIES[name]}`); + } + } + for (const line of floor) console.log(` FAIL [floor] ${line}`); + failed += floor.length; + + const cases = [...seen.values()].reduce((a, b) => a + b, 0); + if (failed > 0) { + console.log(`\n${SCRIPT} self-test: FAILED (${failed} of ${cases} case(s) and floor checks).`); + process.exit(EXIT_FINDINGS); + } + console.log(`\n${SCRIPT} self-test: OK (${cases} case(s) across ${declared.length} batteries).`); + selfTestReachedVerdict = true; +} + +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) { + await selfTest(); + if (!selfTestReachedVerdict) { + console.error( + `\n${SCRIPT} self-test: selfTest() returned without reaching its verdict, so no success line was\n` + + 'printed. Exiting 0 here would report a self-test that never finished as one that passed.', + ); + process.exit(EXIT_FINDINGS); + } + } else { + await main(); + } +} diff --git a/scripts/report-unmeasured-gate-tail.mjs b/scripts/report-unmeasured-gate-tail.mjs deleted file mode 100644 index ebbcc88edec..00000000000 --- a/scripts/report-unmeasured-gate-tail.mjs +++ /dev/null @@ -1,1000 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * report-unmeasured-gate-tail -- turn `Lint & Repo Gates`' abort into a NUMBER. - * - * node scripts/report-unmeasured-gate-tail.mjs # in-job, on failure - * node scripts/report-unmeasured-gate-tail.mjs --self-test # offline, no token - * - * ## What this is for - * - * The `lint` job in `.github/workflows/lint.yml` runs its gates as a sequence of - * separate Actions steps and stops at the first non-zero exit. That is - * DELIBERATE and adjudicated -- see the `GATE ORDER IS DELIBERATE` note above - * `jobs:` in that file, which also names the quantity it is tuned to shrink: - * "the unmeasured tail". This script is the thing that finally prints that - * quantity. - * - * The consequence of aborting is not a smaller signal, it is a DIFFERENT one: - * - * one red gate is a LOWER BOUND on the number of problems in the tree, - * never a count. - * - * A gate that never executed produced no output, and no output is the shape a - * passing gate also has. Reading the tail as "the rest passed" is the zero this - * repo keeps refusing elsewhere: a probe that cannot answer "yes" returns NOT - * MEASURED, not "none". - * - * ## The premise this was built on, measured on three surfaces - * - * The originating card said the abort is invisible -- that "nothing in the - * output says they did not run". Measured against run 34468505490 (job - * 102842713265, `Lint & Repo Gates`, failed at step #55 of 169), that claim is - * TOO STRONG in one place and exactly right in two: - * - * Actions Web UI / jobs API `steps[]` - * SAYS IT, one row at a time: 115 entries came back with - * `conclusion: "skipped"`. It does NOT count them, and `"skipped"` is the - * same word the API uses for a step its own `if:` turned off -- so the - * surface that does say it cannot tell the two apart. A reader wanting - * the number counts grey rows by hand. - * - * check-run `output` - * SAYS NOTHING: `output.title`, `output.summary` and `output.text` were - * all null; the only content was `annotations_count: 1`, the failing - * gate's own annotation. `GITHUB_STEP_SUMMARY` appears zero times in - * lint.yml, so there is no writer for that surface at all. - * - * job log (what an API/agent reader consumes) - * SAYS NOTHING: the log ends with the failing gate's output, then - * `Process completed with exit code 1`, then `Post job cleanup`. Not one - * line about the 115 steps behind it. - * - * So the narrowed, honest defect is: **no surface anywhere carries a COUNT, and - * the two surfaces read by API and by agents carry nothing at all.** This script - * writes the count to all three. - * - * ## Why it reads the API instead of counting locally - * - * The tail's size is `declared - reached`, and only the runner knows `reached`. - * Nothing inside a step exposes which step index is executing, so the count has - * to come from the jobs API for this run. The job therefore needs - * `actions: read`, which is the one permission this adds. - * - * The pre/post boundary in that response is the reading already pinned by - * `scripts/pm/ci-failure.mjs` (`stepsIntegrity`): a job's `steps[]` carries ONE - * numbering gap, at the transition into the runner's reserved `Post ` - * block, and that gap is the normal shape rather than truncation. This file - * re-spells the boundary rather than importing it, to keep the lint job's - * failure reporter off the PM tooling's import graph -- `ci-failure.mjs` pulls - * in `check-governed-merges.mjs`, and a reporter that runs on every red run - * should not go dark because a PM tool moved. - * - * ## What it deliberately does NOT do - * - * * It does not make the skipped gates run. That would undo the adjudicated - * fail-fast ordering and, worse, is the shape that produces cascading false - * reds if the gates ever do depend on each other. - * * It does not use `continue-on-error`, on itself or on anything else. - * Trading a red for a warning is the failure mode this whole idea exists to - * avoid; the job's conclusion is untouched. - * * It never fails the job. It runs only when the job is ALREADY failed, so - * its own non-zero exit could only add a second, misleading annotation. When - * it cannot measure, it says NOT MEASURED and names the reason -- which is - * the whole point -- and still exits 0. - * * It emits exactly ONE `notice` annotation, never a `warning` and never an - * `error`. A notice on an already-failed check cannot be read as a claim - * that something passed, and cannot be read as a downgrade of the red. - * - * ## Cost on a green run - * - * Zero. The step carries `if: failure()`, so on a green head it is skipped: no - * log bytes, no summary bytes, no annotation. The only residue is one more row - * in the job's step list, which is a property of the job, not of its output. - */ - -import process from 'node:process'; -import { appendFileSync, readFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { isEntrypoint } from './invoked-as.mjs'; - -/** The verdict `selfTest()` returns when it reached its own end. */ -const SELF_TEST_VERDICT = 'report-unmeasured-gate-tail:self-test:complete'; - -/** - * Steps the RUNNER injects, which are not gates and must not be counted as - * such. `Set up job` opens every job; the `Post ` block plus - * `Complete job` closes it. No declared step in the `lint` job collides with - * either spelling -- asserted in the self-test. - */ -const INJECTED_HEAD = 'Set up job'; -const POST_STEP = /^Post /; - -/** - * Split one job's `steps[]` into the declared region and the runner's tail - * block. - * - * The boundary is the first `Post ` step; `Complete job` sits behind it - * and so needs no rule of its own. Returning the index rather than a filtered - * array keeps the caller's positions aligned with the API's `number` field. - * - * @param {Array>} steps - * @returns {{ declared: Array>, postIndex: number }} - */ -export function declaredRegion(steps) { - const list = Array.isArray(steps) ? steps : []; - const postIndex = list.findIndex((s) => POST_STEP.test(String(s?.name ?? ''))); - const end = postIndex === -1 ? list.length : postIndex; - const declared = list.slice(0, end).filter((s) => String(s?.name ?? '') !== INJECTED_HEAD); - return { declared, postIndex }; -} - -/** The two conclusions that PROVE a step executed. Everything else does not. */ -const EXECUTED = new Set(['success', 'failure']); - -/** - * Find the step to measure the tail FROM, without trusting the one field that - * is still in flight when this runs. - * - * Three cases, in order, and the third is a REFUSAL rather than a pick: - * - * 1. A declared step carries `conclusion: "failure"`. Final, unambiguous, - * and the only case this file had before -- unchanged. - * - * 2. No step carries it, but the step named by `OS_TAIL_REPORT_STEP` -- this - * reporter -- is itself recorded `in_progress`, and exactly ONE other - * declared step is too. Then that step is the failure, by position: - * - * * this reporter's step declares `if: failure()` in the workflow, so - * its running at all is the runner's own statement that a step in - * this job failed. That wiring is not assumed -- `wiringVerdict()` - * reads it out of `.github/workflows/lint.yml` and the self-test - * fails when it drifts; - * * steps in a job do not overlap, so while this reporter runs, no - * other step is executing. A step the API still shows `in_progress` - * has in fact finished and its completion record has not landed yet; - * * the runner went from that step to the `if: failure()` epilogue - * instead of to the next gate, which is what failing means. - * - * ⭐ This reads `status` and position, never `conclusion`. It is the same - * move `declaredRegion()` already makes for the post block, and the same - * one the count makes behind the failure. - * - * 3. Anything else -- no evidence that the job failed at all (a green job - * has exactly this shape), or evidence that it did but no single - * unfinished step to anchor on (the failing step's START has not landed - * either, so it is indistinguishable from a step never reached). ⛔ Both - * answer NOT MEASURED with a distinct code. Zero and NOT MEASURED are - * different answers, and a guessed position would be worse than the - * honest refusal this file already made. - * - * @param {Array>} declared the declared region - * @param {string} [selfStepName] the name of the step running THIS script - * @returns {{ idx: number, via?: string, code?: string, reason?: string }} - */ -export function anchorFailure(declared, selfStepName = '') { - const list = Array.isArray(declared) ? declared : []; - const nameOf = (s) => String(s?.name ?? '(unnamed step)'); - const statusOf = (s) => String(s?.status ?? ''); - - const byConclusion = list.findIndex((s) => String(s?.conclusion ?? '') === 'failure'); - if (byConclusion !== -1) return { idx: byConclusion, via: 'conclusion' }; - - const selfRunning = - selfStepName !== '' && - list.some((s) => nameOf(s) === selfStepName && statusOf(s) === 'in_progress'); - if (!selfRunning) { - return { - idx: -1, - code: 'no_failure_recorded', - reason: - 'no step in this job reported conclusion "failure", and this reporter is not ' + - 'recorded as running either, so nothing here says the job failed at all', - }; - } - - const unfinished = list - .map((s, i) => ({ s, i })) - .filter(({ s }) => nameOf(s) !== selfStepName && statusOf(s) === 'in_progress'); - if (unfinished.length === 1) return { idx: unfinished[0].i, via: 'status' }; - - return { - idx: -1, - code: 'failure_unstamped', - reason: - 'this reporter runs only on failure() and it is running, so a step in this job failed, ' + - 'but the jobs API carries neither a "failure" conclusion nor exactly one unfinished ' + - `step to anchor on (${unfinished.length} found), so the tail has no measurable start`, - }; -} - -/** - * Count the tail, from one job's `steps[]`. - * - * Pure, so `--self-test` drives the real decision over recorded API responses - * rather than over an imitation of it. - * - * The three populations are kept apart on purpose, because collapsing them is - * the defect this reports on: - * - * ran executed, and said something - * skippedByCondition turned off by its OWN `if:` BEFORE the failure -- a - * deliberate, paid-for absence (the gate-family - * selector), not an unmeasured one - * neverRan behind the failure. Once the job is over the API calls - * these "skipped" too, which is precisely why a reader - * of that surface cannot tell them from the line above. - * - * ## Behind the failure, POSITION is the authority, not `conclusion` - * - * ⚠️ Measured, and it cost a wrong number once: this runs while the job is still - * in progress, and the runner stamps `skipped` on the steps behind the failure - * PROGRESSIVELY. In run 34583803573 of this workflow -- a deliberate - * double-failure probe -- the completed job ended with 161 skipped declared - * steps, but at the instant this step read the API only 12 of them carried that - * word. Counting `conclusion === 'skipped'` therefore reported `never_ran=12` - * for a tail of 161: a lower bound on a lower bound, and a number that reads as - * precise. - * - * So the tail is counted by POSITION -- every declared step behind the failing - * one -- and `conclusion` is used only to SUBTRACT the steps that demonstrably - * did run (an `if: always()`/`if: failure()` step, and this reporter itself, - * which is the one step in `in_progress` while this executes). A step the runner - * has not reached yet is not evidence of anything, and is not read as one. - * - * ## The ANCHOR had the same disease, and it is the field that heals last - * - * ⚠️ Measured over this instrument's whole lifetime (issue #18874; the census - * is comment 5737448141 on that card): of the 42 failing `Lint & Repo Gates` - * jobs that carried this reporter, 24 disclosed NOT MEASURED, and 4 of 4 logs - * read verbatim gave one reason -- no step carried `conclusion: "failure"` at - * the instant of the read. The rule above had been applied to the COUNT and - * written down twice, while the one `conclusion` the anchor still trusted was - * the least likely of all to be final: the step that exited non-zero a - * fraction of a second earlier. The quantity that separates a job that - * measured from one that did not is how many declared steps remain BEHIND the - * failure (Fisher exact, two-sided, p = 0.00099) -- ⛔ NOT the gap between the - * failure and this read, whose smallest value in the timing sub-sample belongs - * to a job that did not measure. So waiting longer is not the repair; not - * depending on that field is. - * - * `anchorFailure()` therefore takes the position from whichever surface can - * carry it, and REFUSES rather than picking one when neither can. It never - * widens what counts as measured on its own: see the three cases there. - * - * @param {Array>} steps a job's `steps[]` from the API - * @param {string} [selfStepName] the name of the step running THIS script, so - * that it never reports itself as a gate that never ran. Supplied by the - * workflow through `OS_TAIL_REPORT_STEP` rather than written here: the literal - * then lives beside the step it names, and if the two ever drift the step - * reappears in the tail as a visible off-by-one rather than going wrong - * quietly. Measured first, in run 34584689309: without it the report ended - * `- Report how many gates never ran`, while that step was the one printing. - * @returns {{ - * measured: boolean, reason?: string, code?: string, anchoredBy: string|null, - * total: number, failedNumber: number|null, - * failedName: string|null, ran: number, skippedByCondition: string[], - * neverRan: string[], alsoFailed: string[], inFlight: string[], - * }} - */ -export function judge(steps, selfStepName = '') { - const empty = { - measured: false, - anchoredBy: null, - total: 0, - failedNumber: null, - failedName: null, - ran: 0, - skippedByCondition: [], - neverRan: [], - alsoFailed: [], - inFlight: [], - }; - - const list = Array.isArray(steps) ? steps : []; - if (list.length === 0) { - return { ...empty, code: 'no_steps', reason: 'the jobs API returned no steps for this job' }; - } - - const { declared } = declaredRegion(list); - if (declared.length === 0) { - return { - ...empty, - code: 'no_declared_steps', - reason: 'the jobs API returned no declared steps for this job', - }; - } - - const anchor = anchorFailure(declared, selfStepName); - if (anchor.idx === -1) { - return { ...empty, total: declared.length, code: anchor.code, reason: anchor.reason }; - } - const failedIdx = anchor.idx; - - const nameOf = (s) => String(s?.name ?? '(unnamed step)'); - const conclusionOf = (s) => String(s?.conclusion ?? ''); - - const before = declared.slice(0, failedIdx); - const after = declared - .slice(failedIdx + 1) - .filter((s) => selfStepName === '' || nameOf(s) !== selfStepName); - - // The only step that can be `in_progress` behind the failure is this reporter, - // because it is the one running right now. Held apart so it is never counted - // as an unmeasured gate. - const inFlight = after.filter((s) => String(s?.status ?? '') === 'in_progress').map(nameOf); - - return { - measured: true, - anchoredBy: anchor.via, - total: declared.length, - failedNumber: Number(declared[failedIdx]?.number ?? 0) || null, - failedName: nameOf(declared[failedIdx]), - // Before the failure every conclusion is already final, so it can be read. - ran: before.filter((s) => conclusionOf(s) !== 'skipped').length, - skippedByCondition: before.filter((s) => conclusionOf(s) === 'skipped').map(nameOf), - // Behind it, none of them are. Position decides; `conclusion` only subtracts. - neverRan: after - .filter((s) => String(s?.status ?? '') !== 'in_progress' && !EXECUTED.has(conclusionOf(s))) - .map(nameOf), - alsoFailed: after.filter((s) => conclusionOf(s) === 'failure').map(nameOf), - inFlight, - }; -} - -/** - * The one machine-readable line. Stable key=value, one line, easy to grep. - * - * Both halves name WHICH of their several shapes they are, appended so that the - * `measured=no never_ran=NOT_MEASURED` and `measured=yes never_ran=N` prefixes - * every existing reader greps for are unchanged: - * - * `reason=` on a refusal, because the annotation surface otherwise collapses - * four different refusals into one string and only the job log keeps them - * apart -- so a census taken from annotations alone cannot tell the race from - * an unreadable API. It could not, when this card was measured. - * - * `anchored_by=` on a measurement, because a fix whose effect cannot be - * counted on the surface the census reads is a fix nobody can confirm. - */ -export function machineLine(v) { - if (!v.measured) { - return ( - 'unmeasured-gate-tail: measured=no never_ran=NOT_MEASURED' + - ` reason=${v.code ?? 'unclassified'}` - ); - } - return ( - 'unmeasured-gate-tail: measured=yes' + - ` never_ran=${v.neverRan.length}` + - ` failed=${1 + v.alsoFailed.length}` + - ` ran=${v.ran}` + - ` skipped_by_condition=${v.skippedByCondition.length}` + - ` declared=${v.total}` + - ` failed_at_step=${v.failedNumber ?? 'unknown'}` + - ` anchored_by=${v.anchoredBy ?? 'conclusion'}` - ); -} - -/** - * The human half, for the job log and the job summary. - * - * The un-run gates are listed in FULL and never truncated: a report about an - * unmeasured tail that itself ends in "... and 94 more" has reproduced the - * defect one level up. - */ -export function renderReport(v) { - const out = []; - out.push(''); - out.push('--- UNMEASURED GATE TAIL ------------------------------------------'); - out.push(''); - out.push('This job stops at the first non-zero exit, deliberately (see the GATE'); - out.push('ORDER note in .github/workflows/lint.yml). So the red above is a LOWER'); - out.push('BOUND on the number of problems in this tree, not a count.'); - out.push(''); - - if (!v.measured) { - out.push(` NOT MEASURED: ${v.reason}.`); - out.push(''); - out.push(' A tail of unknown size is still a tail. Do not read the gates behind'); - out.push(' the failure as passing -- nothing here says they ran.'); - out.push(''); - out.push(` ${machineLine(v)}`); - out.push(''); - return out.join('\n'); - } - - out.push(` failed at step #${v.failedNumber}: ${v.failedName}`); - if (v.anchoredBy === 'status') { - out.push(' (that step was not stamped "failure" yet when this read the API. It'); - out.push(' is the one step the API still showed unfinished, and steps in a job'); - out.push(' do not overlap, so it is where this job stopped -- found by POSITION,'); - out.push(' not by its conclusion. The tail below is unaffected either way.)'); - } - out.push(` declared steps in this job: ${v.total}`); - out.push(` ran before the failure: ${v.ran}`); - out.push(` turned off by their own condition: ${v.skippedByCondition.length}`); - out.push(` NEVER RAN (the unmeasured tail): ${v.neverRan.length}`); - if (v.alsoFailed.length > 0) { - out.push(` further steps that also FAILED: ${v.alsoFailed.length}`); - } - out.push(''); - - if (v.neverRan.length === 0) { - out.push(' The tail is empty: every other gate in this job reported. For THIS'); - out.push(' run the red count above is a count, not a lower bound.'); - } else { - out.push(` These ${v.neverRan.length} gate(s) produced no output because they never executed.`); - out.push(' That is NOT MEASURED, not "passed". Fix the failure above and re-run'); - out.push(' to find out what they would have said:'); - out.push(''); - for (const name of v.neverRan) out.push(` - ${name}`); - } - - if (v.skippedByCondition.length > 0) { - out.push(''); - out.push(' Turned off by their own condition (a deliberate absence, not this'); - out.push(' tail) -- the jobs API reports these with the same "skipped" word:'); - for (const name of v.skippedByCondition) out.push(` - ${name}`); - } - - out.push(''); - out.push(` ${machineLine(v)}`); - out.push(''); - return out.join('\n'); -} - -/** The same verdict as GitHub-flavoured markdown, for the job summary. */ -export function renderSummary(v) { - const lines = ['## Unmeasured gate tail', '']; - if (!v.measured) { - lines.push(`**NOT MEASURED** — ${v.reason}.`, '', '```', machineLine(v), '```', ''); - return lines.join('\n'); - } - lines.push( - `This job aborts at the first non-zero exit, so the red is a **lower bound**, not a count.`, - '', - `| | |`, - `|:--|--:|`, - `| failed at step #${v.failedNumber} | \`${v.failedName}\` |`, - `| ran before the failure | ${v.ran} |`, - `| turned off by their own condition | ${v.skippedByCondition.length} |`, - `| **never ran (unmeasured tail)** | **${v.neverRan.length}** |`, - '', - ); - if (v.anchoredBy === 'status') { - lines.push( - 'The failing step was not stamped `failure` yet at the instant of this read; it was' + - ' located by position (the one step the API still showed unfinished) rather than by' + - ' its conclusion.', - '', - ); - } - if (v.neverRan.length > 0) { - lines.push('
The gates that never ran', ''); - for (const name of v.neverRan) lines.push(`- ${name}`); - lines.push('', '
', ''); - } - lines.push('```', machineLine(v), '```', ''); - return lines.join('\n'); -} - -// -- the live half ---------------------------------------------------------- - -/** - * Fetch every job of this run, following pagination. - * - * @param {{ repository: string, runId: string, token: string, apiUrl: string, fetchImpl?: typeof fetch }} opts - */ -export async function fetchRunJobs({ repository, runId, token, apiUrl, fetchImpl }) { - const doFetch = fetchImpl ?? fetch; - const jobs = []; - for (let page = 1; page <= 10; page++) { - const url = `${apiUrl}/repos/${repository}/actions/runs/${runId}/jobs?per_page=100&page=${page}`; - const res = await doFetch(url, { - headers: { - accept: 'application/vnd.github+json', - authorization: `Bearer ${token}`, - 'x-github-api-version': '2022-11-28', - }, - }); - if (!res.ok) throw new Error(`GET ${url} answered ${res.status}`); - const body = await res.json(); - const batch = Array.isArray(body?.jobs) ? body.jobs : []; - jobs.push(...batch); - if (batch.length < 100) break; - } - return jobs; -} - -/** - * Pick THIS job out of the run's jobs. - * - * Matched on `runner_name`, which the runner also exports as `RUNNER_NAME`, so - * nothing here hardcodes the job's `name:` -- that literal is the required - * status-check context (lint.yml's own note), and a reporter that silently - * stopped matching when it was renamed would be the same class of defect this - * file exists to remove. The in-progress filter breaks the tie if a runner name - * is ever reused inside one run. - * - * @param {Array>} jobs - * @param {string} runnerName - */ -export function pickOwnJob(jobs, runnerName) { - const list = Array.isArray(jobs) ? jobs : []; - const byRunner = list.filter((j) => String(j?.runner_name ?? '') === runnerName && runnerName !== ''); - if (byRunner.length === 1) return byRunner[0]; - const running = (byRunner.length > 1 ? byRunner : list).filter( - (j) => String(j?.status ?? '') === 'in_progress', - ); - if (running.length === 1) return running[0]; - return null; -} - -async function main() { - const env = process.env; - const token = env.GITHUB_TOKEN ?? ''; - const repository = env.GITHUB_REPOSITORY ?? ''; - const runId = env.GITHUB_RUN_ID ?? ''; - const apiUrl = env.GITHUB_API_URL ?? 'https://api.github.com'; - const runnerName = env.RUNNER_NAME ?? ''; - - const missing = [ - ['GITHUB_TOKEN', token], - ['GITHUB_REPOSITORY', repository], - ['GITHUB_RUN_ID', runId], - ] - .filter(([, value]) => value === '') - .map(([key]) => key); - - /** @type {ReturnType} */ - let verdict; - if (missing.length > 0) { - verdict = judge([]); - verdict.code = 'env_missing'; - verdict.reason = `${missing.join(', ')} not set, so this run's steps could not be read`; - } else { - try { - const jobs = await fetchRunJobs({ repository, runId, token, apiUrl }); - const own = pickOwnJob(jobs, runnerName); - if (own === null) { - verdict = judge([]); - verdict.code = 'job_unidentified'; - verdict.reason = - `none of the ${jobs.length} job(s) in run ${runId} could be identified as this one ` + - `(runner ${runnerName || 'unnamed'})`; - } else { - verdict = judge(own.steps, env.OS_TAIL_REPORT_STEP ?? ''); - } - } catch (err) { - verdict = judge([]); - verdict.code = 'api_error'; - verdict.reason = `the jobs API could not be read: ${err instanceof Error ? err.message : String(err)}`; - } - } - - console.log(renderReport(verdict)); - - // The annotation surface. `notice` on purpose: a check-run that is already - // failed keeps its conclusion, and a notice can be read neither as a pass nor - // as a downgrade of the red. - console.log(`::notice title=Unmeasured gate tail::${machineLine(verdict)}`); - - const summaryPath = env.GITHUB_STEP_SUMMARY; - if (summaryPath) { - try { - appendFileSync(summaryPath, `${renderSummary(verdict)}\n`, 'utf8'); - } catch (err) { - console.log(` (job summary not written: ${err instanceof Error ? err.message : String(err)})`); - } - } - - // Always 0. This step runs only when the job has ALREADY failed; a non-zero - // exit here would add a second failure annotation that points at the reporter - // instead of at the gate, and would say nothing the job's conclusion does not - // already say. - process.exit(0); -} - -// -- self-test -------------------------------------------------------------- - -/** - * `OS_TAIL_REPORT_STEP` must be the literal `name:` of the step that sets it, - * and that step must declare `if: failure()`. - * - * Read as plain text on purpose: the assertion is about two LINES of the - * workflow agreeing, it needs no schema, and a text read works before - * `pnpm install` has put a YAML parser on disk -- which matters, because the - * step this file backs is the one that reports on a job that may have died at - * the install step. - * - * ⭐ The condition half is not decoration. `anchorFailure()` case 2 reads this - * reporter's own `in_progress` record as the runner's statement that a step in - * this job failed, and that inference is licensed by `if: failure()` and by - * nothing else. Loosen the condition and the positional anchor would start - * answering on jobs that never failed -- so the loosening fails HERE, in a - * gate that runs on every red job, rather than silently in production. - * - * @param {string} source the text of `.github/workflows/lint.yml` - * @returns {{ ok: boolean, note: string }} - */ -export function wiringVerdict(source) { - const lines = String(source).split('\n'); - const found = []; - for (let i = 0; i < lines.length; i++) { - const env = /^\s*OS_TAIL_REPORT_STEP:\s*(.+?)\s*$/.exec(lines[i]); - if (env === null) continue; - let declared = null; - let condition = null; - for (let j = i - 1; j >= 0 && j > i - 40; j--) { - const when = /^\s*if:\s*(.+?)\s*$/.exec(lines[j]); - if (when !== null && condition === null) condition = when[1]; - const name = /^\s*- name:\s*(.+?)\s*$/.exec(lines[j]); - if (name !== null) { - declared = name[1].replace(/^['"]|['"]$/g, ''); - break; - } - } - found.push({ env: env[1].replace(/^['"]|['"]$/g, ''), declared, condition }); - } - if (found.length === 0) { - return { ok: false, note: 'no step sets OS_TAIL_REPORT_STEP, so this reporter would list itself' }; - } - const wrong = found.filter((f) => f.declared !== f.env); - if (wrong.length > 0) { - return { - ok: false, - note: `OS_TAIL_REPORT_STEP does not match its own step name: ${JSON.stringify(wrong)}`, - }; - } - const unconditioned = found.filter((f) => !/failure\(\)/.test(String(f.condition ?? ''))); - if (unconditioned.length > 0) { - return { - ok: false, - note: - 'the step that sets OS_TAIL_REPORT_STEP no longer declares `if: failure()`, so this ' + - 'reporter running is no longer evidence that the job failed and the positional ' + - `anchor would answer on green jobs: ${JSON.stringify(unconditioned)}`, - }; - } - return { - ok: true, - note: `${found.length} step(s) set OS_TAIL_REPORT_STEP to their own name, under \`if: failure()\``, - }; -} - -/** This repo's root, resolved from this file rather than from the cwd. */ -function repoRoot() { - return resolve(dirname(fileURLToPath(import.meta.url)), '..'); -} - -/** - * Recorded `steps[]` shapes, abbreviated from real jobs of this repo. - * - * `lintFailedEarly` is job 102842713265 of run 34468505490 reduced to its - * shape: `Set up job`, then declared steps, one of which failed, then skipped - * steps, then the reserved post block with its numbering gap. - */ -function fixtures() { - const step = (number, name, conclusion, status = 'completed') => ({ number, name, conclusion, status }); - const lintFailedEarly = [ - step(1, INJECTED_HEAD, 'success'), - step(2, 'Checkout repository', 'success'), - step(3, 'Install dependencies', 'success'), - step(4, 'Slot-lookup ratchet', 'skipped'), - step(5, 'Docs anchors resolve to real headings', 'success'), - step(6, 'Doc/skill authoring guard', 'failure'), - step(7, 'Docs frontmatter parses', 'skipped'), - step(8, 'One `

` per docs page', 'skipped'), - step(9, 'Report the unmeasured gate tail', null, 'in_progress'), - step(332, 'Post Setup pnpm cache', 'skipped'), - step(333, 'Post Checkout repository', 'success'), - step(334, 'Complete job', 'success'), - ]; - const lintFailedLast = [ - step(1, INJECTED_HEAD, 'success'), - step(2, 'Checkout repository', 'success'), - step(3, 'ESLint', 'success'), - step(4, 'Duration-shaped spec keys carry their unit in the key name', 'failure'), - step(5, 'Report the unmeasured gate tail', null, 'in_progress'), - step(336, 'Post Checkout repository', 'success'), - step(337, 'Complete job', 'success'), - ]; - const green = [ - step(1, INJECTED_HEAD, 'success'), - step(2, 'Checkout repository', 'success'), - step(3, 'ESLint', 'success'), - step(336, 'Post Checkout repository', 'success'), - step(337, 'Complete job', 'success'), - ]; - // The shape this actually runs against, and the one that produced a wrong - // number before the rule above existed: the job is still in progress, so the - // runner has stamped `skipped` on only the first slice of the tail and has - // written nothing at all for the rest. - const midRun = [ - step(1, INJECTED_HEAD, 'success'), - step(2, 'Checkout repository', 'success'), - step(3, 'Docs anchors resolve to real headings', 'failure'), - step(4, 'ESLint', 'skipped'), - step(5, 'Raw control-byte guard', 'skipped'), - step(6, 'Slot-lookup ratchet', null, 'queued'), - step(7, 'ADR anchors + number uniqueness', null, 'queued'), - step(8, 'Duration-shaped spec keys carry their unit in the key name', null, 'queued'), - step(9, 'Report how many gates never ran', null, 'in_progress'), - step(340, 'Post Checkout repository', 'success'), - step(341, 'Complete job', 'success'), - ]; - // ⭐ The shape the recorded fixtures above could NOT express, and the one - // this reporter meets on more than half of the jobs it runs on: at the - // instant of the read the failing step has no final conclusion yet. The - // runner has already moved on to the `if: failure()` epilogue -- the - // self-test step below ran and passed -- but the jobs API still answers with - // the last state that step was recorded in, `in_progress`, and has written - // nothing at all for the gates behind it. Every fixture above hands the - // anchor a stamped `failure`, which is why 32 green assertions were - // compatible with a reporter that disclosed NOT MEASURED in production. - const failureNotYetStamped = [ - step(1, INJECTED_HEAD, 'success'), - step(2, 'Checkout repository', 'success'), - step(3, 'Install dependencies', 'success'), - step(4, 'Slot-lookup ratchet', 'skipped'), - step(5, 'Docs anchors resolve to real headings', 'success'), - step(6, 'Doc/skill authoring guard', null, 'in_progress'), - step(7, 'Docs frontmatter parses', null, 'queued'), - step(8, 'One `

` per docs page', null, 'queued'), - step(9, 'Unmeasured-gate-tail reporter self-test', 'success'), - step(10, 'Report how many gates never ran', null, 'in_progress'), - step(340, 'Post Checkout repository', 'success'), - step(341, 'Complete job', 'success'), - ]; - // One propagation step earlier still: the failing step's START has not landed - // either, so from this surface it is indistinguishable from a gate the runner - // never reached. There is a position here, but no evidence for it. - const failureNotYetStarted = failureNotYetStamped.map((x) => - x.name === 'Doc/skill authoring guard' ? { ...x, status: 'queued' } : x); - return { lintFailedEarly, lintFailedLast, green, midRun, failureNotYetStamped, failureNotYetStarted }; -} - -function selfTest() { - const failures = []; - let checked = 0; - const t = (label, actual, expected) => { - checked++; - const a = JSON.stringify(actual); - const e = JSON.stringify(expected); - if (a !== e) failures.push(`${label}: expected ${e}, got ${a}`); - }; - - const { lintFailedEarly, lintFailedLast, green, midRun, failureNotYetStamped, - failureNotYetStarted } = fixtures(); - /** The literal `.github/workflows/lint.yml` passes through OS_TAIL_REPORT_STEP. */ - const REPORTER = 'Report how many gates never ran'; - - // -- the boundary: the post block is not part of the population ------------ - t('declaredRegion drops the runner head and the whole post block', - declaredRegion(lintFailedEarly).declared.map((s) => s.name), - ['Checkout repository', 'Install dependencies', 'Slot-lookup ratchet', - 'Docs anchors resolve to real headings', 'Doc/skill authoring guard', - 'Docs frontmatter parses', 'One `

` per docs page', - 'Report the unmeasured gate tail']); - t('the numbering gap before the post block is NOT read as truncation', - declaredRegion(lintFailedEarly).postIndex, 9); - - // -- the three populations stay apart -------------------------------------- - const early = judge(lintFailedEarly); - t('an early failure is measured', early.measured, true); - t('...names the failing step', [early.failedNumber, early.failedName], [6, 'Doc/skill authoring guard']); - t('...counts the tail behind the failure', early.neverRan, - ['Docs frontmatter parses', 'One `

` per docs page']); - t('...keeps an `if:`-skipped step OUT of the tail', early.skippedByCondition, ['Slot-lookup ratchet']); - t('...counts what actually ran', early.ran, 3); - t('...never counts this reporter itself as an unmeasured gate', early.inFlight, - ['Report the unmeasured gate tail']); - - // The point of the whole exercise: the two "skipped" populations are the same - // word on the API surface and MUST NOT be the same number here. - t('a deliberate skip and an unmeasured one are different numbers', - [early.skippedByCondition.length, early.neverRan.length], [1, 2]); - - // -- mid-run: a not-yet-stamped step is part of the tail, not evidence ------ - // The regression this pins: counting `conclusion === 'skipped'` answered 12 - // on a live job whose real tail was 161, because the runner had not reached - // the rest yet. Position decides; `conclusion` may only subtract. - const mid = judge(midRun); - t('a step the runner has not reached yet is counted in the tail', - mid.neverRan, - ['ESLint', 'Raw control-byte guard', 'Slot-lookup ratchet', - 'ADR anchors + number uniqueness', - 'Duration-shaped spec keys carry their unit in the key name']); - t('...so the tail is the POSITION count, not the stamped-skipped count', - [mid.neverRan.length, midRun.filter((x) => x.conclusion === 'skipped').length], [5, 2]); - t('...and this reporter, the one step in flight, is never in it', - [mid.inFlight, mid.neverRan.includes('Report how many gates never ran')], - [['Report how many gates never ran'], false]); - - // The reporter is not always stamped `in_progress` by the time it reads the - // API -- measured in run 34584689309, where it listed ITSELF in its own tail. - // The `in_progress` rule alone does not catch that; the name does. - const selfNotYetMarked = midRun.map((x) => - x.name === 'Report how many gates never ran' ? { ...x, status: 'queued' } : x); - t('an unmarked reporter WOULD list itself, which is why the workflow passes a name', - judge(selfNotYetMarked).neverRan.includes('Report how many gates never ran'), true); - t('...and naming it removes exactly that one entry, nothing else', - [judge(selfNotYetMarked, 'Report how many gates never ran').neverRan.includes('Report how many gates never ran'), - judge(selfNotYetMarked).neverRan.length - - judge(selfNotYetMarked, 'Report how many gates never ran').neverRan.length], - [false, 1]); - t('a name that matches nothing changes no count', - judge(selfNotYetMarked, 'Report how many gates NEVER ran').neverRan.length, - judge(selfNotYetMarked).neverRan.length); - - // -- an empty tail is a real, different answer ------------------------------ - const last = judge(lintFailedLast); - t('a failure in the last gate reports an EMPTY tail rather than staying silent', - [last.measured, last.neverRan.length], [true, 0]); - t('...and says so in words', renderReport(last).includes('The tail is empty'), true); - - // -- the anchor: at the instant of the read the failure has no conclusion --- - // The defect this battery could not previously express (#18874). Every - // fixture above hands `judge()` a failing step already stamped `failure`; - // this one is the live shape, where it is not. The card's mechanism was read - // verbatim out of 4 of 4 production logs, and the census of all 42 jobs the - // instrument ever ran on is comment 5737448141. - t('the race fixture carries NO stamped failure at all -- which is why the old anchor refused it', - failureNotYetStamped.some((x) => x.conclusion === 'failure'), false); - const racing = judge(failureNotYetStamped, REPORTER); - t('a failing step the API has not stamped yet is still found', racing.measured, true); - t('...by position, and the verdict says so rather than implying a final conclusion', - [racing.anchoredBy, racing.failedNumber, racing.failedName], - ['status', 6, 'Doc/skill authoring guard']); - t('...and the tail is the unreached gates ONLY: the `if: failure()` epilogue ran, so it is not in it', - racing.neverRan, ['Docs frontmatter parses', 'One `

` per docs page']); - t('...while everything before the failure is read exactly as before', - [racing.ran, racing.skippedByCondition], [3, ['Slot-lookup ratchet']]); - t('...and the machine line carries the anchor it used, for a census to count', - machineLine(racing), - 'unmeasured-gate-tail: measured=yes never_ran=2 failed=1 ran=3 skipped_by_condition=1 ' + - 'declared=9 failed_at_step=6 anchored_by=status'); - - // ⛔ The positional anchor is licensed by this reporter's own `if: failure()` - // and by nothing else. Unnamed, it must NOT fire -- otherwise the repair - // would invent a failure on a job that never had one. - t('without the reporter named, the position is not licensed and the answer stays NOT MEASURED', - [judge(failureNotYetStamped).measured, judge(failureNotYetStamped).code], - [false, 'no_failure_recorded']); - - // ⛔ And it refuses rather than guessing when the position is not evidenced. - const notStarted = judge(failureNotYetStarted, REPORTER); - t('a failing step whose START has not landed either is NOT MEASURED, never a guessed position', - [notStarted.measured, notStarted.code, notStarted.neverRan.length], - [false, 'failure_unstamped', 0]); - t('...and its reason says a failure DID happen, so the number is missing rather than zero', - notStarted.reason.includes('a step in this job failed'), true); - const twoUnfinished = failureNotYetStamped.map((x) => - x.name === 'Docs anchors resolve to real headings' - ? { ...x, conclusion: null, status: 'in_progress' } : x); - t('two unfinished steps are an ambiguity, and an ambiguity is NOT MEASURED, not a pick', - [judge(twoUnfinished, REPORTER).measured, judge(twoUnfinished, REPORTER).code], - [false, 'failure_unstamped']); - t('a stamped failure still wins over any position, so nothing above changes the settled case', - [judge(lintFailedEarly, REPORTER).anchoredBy, judge(midRun).anchoredBy], - ['conclusion', 'conclusion']); - - // -- NOT MEASURED is never rendered as zero -------------------------------- - const noFailure = judge(green); - t('a job with no failed step is NOT MEASURED, not a tail of 0', noFailure.measured, false); - t('...and its machine line says NOT_MEASURED rather than 0', - machineLine(noFailure), - 'unmeasured-gate-tail: measured=no never_ran=NOT_MEASURED reason=no_failure_recorded'); - t('...for the reason that nothing says it failed, not for the race reason', - noFailure.code, 'no_failure_recorded'); - t('an empty steps array is NOT MEASURED too', judge([]).measured, false); - t('a null steps value is NOT MEASURED too', judge(null).measured, false); - t('...and each refusal names which shape it is, rather than collapsing into one string', - [judge([]).code, judge([{ name: 'Post x', conclusion: 'success' }]).code], - ['no_steps', 'no_declared_steps']); - - // -- the machine-readable line --------------------------------------------- - t('the machine line carries every count', - machineLine(early), - 'unmeasured-gate-tail: measured=yes never_ran=2 failed=1 ran=3 skipped_by_condition=1 ' + - 'declared=8 failed_at_step=6 anchored_by=conclusion'); - - // -- the report never truncates the list it is about ------------------------ - const manyNames = Array.from({ length: 120 }, (_, i) => `gate ${i}`); - const manyRendered = renderReport({ ...early, neverRan: manyNames }); - t('every un-run gate is named, with nothing elided', - manyNames.every((n) => manyRendered.includes(` - ${n}`)), true); - - // -- output must not mint workflow commands of its own --------------------- - // `check:self-test-workflow-commands` reads this file's self-test output; a - // legacy `##[` token is parsed ANYWHERE in a line and a `::` form at line - // start, so neither may appear in anything rendered here. - // The two tokens are BUILT rather than written, so that a failing assertion - // here cannot print the very thing it refuses. - const legacyToken = `${'#'.repeat(2)}[`; - const modernToken = ':'.repeat(2); - const renderedLines = [ - renderReport(early), - renderReport(noFailure), - renderSummary(early), - renderSummary(noFailure), - ] - .join('\n') - .split('\n'); - t('no rendered line carries the legacy command token, which parses anywhere in a line', - renderedLines.some((l) => l.includes(legacyToken)), false); - t('no rendered line starts with the modern command token, which parses at line start', - renderedLines.some((l) => l.startsWith(modernToken)), false); - - // -- the workflow wiring, read from the real file --------------------------- - const workflow = readFileSync(join(repoRoot(), '.github/workflows/lint.yml'), 'utf8'); - const wiring = wiringVerdict(workflow); - t(`lint.yml wires OS_TAIL_REPORT_STEP to its own step name (${wiring.note})`, wiring.ok, true); - t('a renamed step with a stale env value is caught', - wiringVerdict(' - name: Renamed\n env:\n OS_TAIL_REPORT_STEP: Old name\n').ok, - false); - t('a missing wiring is caught too, not read as clean', - wiringVerdict(' - name: Report how many gates never ran\n run: node x.mjs\n').ok, false); - t('a quoted step name matches its unquoted env value', - wiringVerdict( - " - name: 'A name'\n if: failure()\n env:\n OS_TAIL_REPORT_STEP: A name\n", - ).ok, true); - // ⛔ The condition is what licenses `anchorFailure()` case 2. Dropped or - // widened, the positional anchor would start answering on jobs that never - // failed -- so it fails here instead. - t('a reporter step that no longer declares `if: failure()` is caught', - wiringVerdict( - ' - name: A name\n env:\n OS_TAIL_REPORT_STEP: A name\n', - ).ok, false); - t('...and so is one whose condition was widened to always()', - wiringVerdict( - ' - name: A name\n if: always()\n env:\n OS_TAIL_REPORT_STEP: A name\n', - ).ok, false); - - // -- picking this job out of the run --------------------------------------- - const jobs = [ - { name: 'Type Check', runner_name: 'GitHub Actions 1', status: 'completed', steps: [] }, - { name: 'Lint & Repo Gates', runner_name: 'GitHub Actions 2', status: 'in_progress', steps: lintFailedEarly }, - ]; - t('this job is found by runner name, with no job `name:` literal anywhere', - pickOwnJob(jobs, 'GitHub Actions 2')?.name, 'Lint & Repo Gates'); - t('an unknown runner falls back to the single in-progress job', - pickOwnJob(jobs, 'GitHub Actions 999')?.name, 'Lint & Repo Gates'); - t('an unidentifiable job is null, never a guess', - pickOwnJob([jobs[0]], 'GitHub Actions 999'), null); - - if (failures.length) { - console.error(`x report-unmeasured-gate-tail --self-test -- ${failures.length} failure(s)\n`); - for (const f of failures) console.error(` - ${f}`); - process.exit(1); - } - console.log( - `+ report-unmeasured-gate-tail --self-test: ${checked} assertions over recorded jobs-API shapes ` + - '(real judge()/renderReport() path; the two "skipped" populations, the empty tail, ' + - 'NOT MEASURED vs zero, the no-truncation rule, and the live shape in which the ' + - 'failing step carries no final conclusion yet)', - ); - return SELF_TEST_VERDICT; -} - -// Exports bindings, so an import for those exports alone must run nothing (#10667). -const invokedDirectly = isEntrypoint(import.meta.url); - -if (!invokedDirectly) { - // imported as a module -- expose the exports and do nothing else -} else if (process.argv.includes('--self-test')) { - if (selfTest() !== SELF_TEST_VERDICT) { - console.error( - '\nx report-unmeasured-gate-tail self-test: selfTest() returned without reaching its\n' + - 'verdict, so no success line was printed. Exiting 0 here would report a self-test\n' + - 'that never finished as a self-test that passed.\n', - ); - process.exit(1); - } -} else { - await main(); -}