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();
-}