meta.deleteItem sends the reset door's If-Match pin and ?state=draft, on both declarations #26632
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Lint & Type Check | |
| on: | |
| push: | |
| branches: | |
| - main | |
| pull_request: | |
| branches: | |
| - main | |
| # Merge queue (see ci.yml for the full note): required checks must report on | |
| # queue builds or the queue stalls. This workflow has no PR-only steps, so | |
| # the trigger alone is enough. | |
| merge_group: | |
| # ── MEASURED 2026-08-25 (#12211) — a NEGATIVE result, recorded so it is not | |
| # re-measured. After the stale-ledger outage the queue's check set was measured | |
| # rather than assumed, because two observations of that day read as | |
| # contradictory: a PR landing an inconsistent tree at 11:50Z (which looked like | |
| # "the consumer-gates lane does not run in the queue"), and five queue entries | |
| # dequeued 12:26-13:05Z while main was red (which looked like the opposite). | |
| # | |
| # Both halves were read, and there is NO merge_group coverage gap to close: | |
| # | |
| # - STATIC. Every gate step in this workflow runs on merge_group; the claim | |
| # three lines above is exact. The only event-conditioned step in the file is | |
| # `Save Turbo cache (main only)`, which stores a cache and judges nothing. | |
| # ci.yml is the same shape — its single merge_group exclusion sits on the | |
| # paths filter, which on a queue build widens to "everything changed" | |
| # rather than narrowing. All six required contexts live in these two | |
| # merge_group-triggered workflows (scripts/check-required-contexts.mjs). | |
| # | |
| # - DYNAMIC. 180 merge_group runs of this workflow were read across | |
| # 09:15-18:49Z. In run 32847794799 — a real queue build — the | |
| # `Type Check · consumer gates` lane RAN and FAILED on | |
| # `check:exported-any-returns`, and the required `TypeScript Type Check` | |
| # aggregate carried that red into the queue. Tree-global ratchets do kick | |
| # there; the 12:26-13:05Z dequeues are that gate, on innocent candidates, | |
| # while main itself was red. CI was green on those same merge groups. | |
| # | |
| # What the 11:50Z landing actually was: that commit produced NO merge_group | |
| # build at all — 4 of the 29 PRs landing 09:20-12:10Z produced none — and its | |
| # PR-level required checks were green as measured at 07:20Z, ~3h before the | |
| # gate that would have refused it existed. The residual is therefore not a | |
| # trigger this file is missing. It is that a commit can reach main without a | |
| # queue build, plus `strict_required_status_checks_policy: false` on the `main` | |
| # ruleset (measured 2026-08-18, #9642). Both are repository SETTINGS and | |
| # maintainer-only; neither is reachable from this file. | |
| # | |
| # ⛔ So do not "close the gap" by adding merge_group to the advisory workflows | |
| # (check-links.yml, docs-drift-check.yml, validate-deps.yml, the patrols). A | |
| # non-required check on a queue build costs runners and blocks nothing, and | |
| # check-links.yml's own header already refuses it for that reason. | |
| # Same policy as ci.yml: superseded runs on the same PR/branch waste runners | |
| # and delay feedback; cancel them. Push runs to main group by commit ref, so an | |
| # in-flight main run is cancelled only by a newer main push. | |
| concurrency: | |
| group: lint-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # ⭐ GATE INVOCATION IDIOM — stated ONCE, here. The steps below point at it. | |
| # | |
| # Most gate steps in this file run their script directly: | |
| # | |
| # run: node scripts/check-<thing>.mjs | |
| # | |
| # rather than through a `pnpm check:*` alias. That is a deliberate in-repo | |
| # idiom with its own justification, not a workaround: | |
| # | |
| # * Many gate steps in this file already use it — it is the local precedent. | |
| # * `scripts/pm/dispatch-gates.mjs` derives gate families from EITHER | |
| # spelling, so the direct form loses no discovery. | |
| # * It adds no key to the root manifest. | |
| # * It is pinned rather than incidental: check-aggregator-roster, | |
| # check-ci-filter-parity and check-doc-frontmatter each assert in their own | |
| # self-test that `node <self>` appears in THIS file, so silently rewiring | |
| # one of them to an alias reddens its gate. | |
| # | |
| # ⚠️ It is NOT because root `package.json` is off limits. This file used to say | |
| # that, in one shape or another, at nineteen separate sites, and it is FALSE. | |
| # The declared territory of the #9465 @changesets/cli v3 lane inside that file | |
| # is scoped by the lane's own parenthetical, verbatim from the issue body: | |
| # | |
| # root `package.json` (the `@changesets/cli` range and the `version` script) | |
| # | |
| # That parenthetical is SCOPING, not illustrative: one dependency range and one | |
| # script key. It does not cover the file, and it does not cover a new `check:*` | |
| # key — so adding `check:<thing>` to the root manifest would have been allowed | |
| # all along. The steps below take the direct form on the merits listed above; | |
| # they are not fenced out of the alternative. | |
| # | |
| # Stated once here, and POINTED AT rather than restated, because the over-broad | |
| # reading propagates by copying — measured, not supposed. A dev agent wrote the | |
| # same sentence into a new comment and a new script's docblock and said plainly: | |
| # "I copied the phrasing from neighbouring comments instead of checking it." | |
| # The count was 15 sites when #10894 was filed on 2026-08-21 and 19 by | |
| # 2026-08-24, four of them added AFTER the card was filed and two of those | |
| # within ninety minutes of it being picked up. Nineteen CORRECTED copies would | |
| # re-arm the identical mechanism: a claim that reads as verified because it | |
| # reads exactly like the verified ones beside it. So there is one statement of | |
| # this idiom — this one — and every step refers to it. | |
| # | |
| # Changing this note? It is prose, and no gate can red on a wrong sentence | |
| # here; that is precisely how nineteen wrong ones accumulated. Check any claim | |
| # about the #9465 fence against #9465's own body before you write it down. | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| jobs: | |
| lint: | |
| # ⚠️ This `name:` IS the required-status-check context in repository | |
| # Settings → Rulesets (a job's check-run name is its `name:`), so it is | |
| # pinned as contract by `scripts/check-required-contexts.mjs` and may only | |
| # be changed together with that registry AND the Settings entry, in one | |
| # maintainer-present sitting (#9325 ruling 2026-08-17). Either half alone | |
| # is an outage: rename-first leaves the old context permanently pending, | |
| # which wedges every open PR and the merge queue; settings-first drops the | |
| # whole gate family to advisory with no signal anywhere, which is #5617 | |
| # verbatim. | |
| # | |
| # It is not called `ESLint` any more because it never was only that: the | |
| # `pnpm lint` step below is one of ~70 sequential gate steps, so a red here | |
| # was routinely read as "a lint problem" when it was a repo gate — three | |
| # mis-routed diagnoses on 2026-08-17 alone (#9258, PRs #9256/#9291). | |
| name: Lint & Repo Gates | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v7 | |
| with: | |
| # The slot-lookup ratchet compares the baseline against its state at | |
| # the merge base with main — the only way to see a file being ADDED | |
| # to the grandfather list. A shallow clone has no merge base, and the | |
| # check would degrade to "not verified" on every run. | |
| fetch-depth: 0 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: '22' | |
| - name: Setup pnpm | |
| uses: ./.github/actions/setup-pnpm | |
| - name: Get pnpm store directory | |
| shell: bash | |
| run: | | |
| echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV | |
| - name: Setup pnpm cache | |
| uses: actions/cache@v6 | |
| with: | |
| path: ${{ env.STORE_PATH }} | |
| key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} | |
| restore-keys: | | |
| ${{ runner.os }}-pnpm-store-v3- | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| # Enforces the no-restricted-imports guard against @objectstack/spec root | |
| # namespace imports (the dormant rule was never run in CI). Syntactic | |
| # only, so no build step needed. | |
| - name: ESLint | |
| run: pnpm lint | |
| # Slot-lookup sweep ratchet (#4251). `pnpm lint` above bans erasing a | |
| # service-lookup result to `any` across packages/, but the files still | |
| # holding pre-existing sites are grandfathered by path — and an ignored | |
| # file is ignored COMPLETELY, so new erasures added to one ride the old | |
| # entry in silence. This re-measures those files with the grandfathering | |
| # lifted and holds them to a per-file count, so the list can only shrink. | |
| - name: Slot-lookup ratchet | |
| run: pnpm check:slot-lookup | |
| # Engine query-options erasure ratchet (#4918). The slot-lookup rule above | |
| # protects the service LOOKUP; this one protects what you pass to the | |
| # service you looked up. `IDataEngine.find/findOne/count/aggregate` declare | |
| # their options as `EngineQueryOptions` & co., and for an internal caller | |
| # `tsc` is the ONLY channel enforcing them — the protocol's ingress | |
| # normalizer never sees a direct engine call, and the options schemas are | |
| # not `.strict()`, so an unknown key is silently DROPPED. #4674 is the | |
| # bill: two queries sorted by `direction` instead of `order`, both with a | |
| # `limit`, so both returned the OLDEST rows — audit history that never | |
| # showed recent changes, and a search that truncated away fresh records. | |
| # #4720 restored those two sites and #4721 closed the external callers; | |
| # this stops the shape regrowing internally. Same ratchet mechanism as | |
| # slot-lookup for the non-test residual, plus one aggregate decrease-only | |
| # number for test code (a test whose subject IS off-contract engine input | |
| # must be able to build it). Runs its own --self-test first. | |
| - name: Engine query-options erasure ratchet | |
| run: pnpm check:query-options-erasure | |
| # @objectstack/verify stand-in erasure guard (#6399). The third and | |
| # narrowest member of the two above. `checkReadCoercion` / | |
| # `checkDateBucketParity` take their driver STRUCTURALLY | |
| # (`CoercibleDriver` / `BucketableDriver`) so an out-of-tree driver — | |
| # cloud's driver-turso in remote mode — runs the identical contract | |
| # without importing a concrete driver type; that parameter type IS the | |
| # compile-time half of the conformance, and an assertion on the argument | |
| # deletes it for that call site while looking like a call that has it. | |
| # #6354 / PR #6396 is the bill: ten `as never` casts, every call site of | |
| # both helpers, all of them dead, sitting over a check that was provably | |
| # alive — and no gate rang for either fact. | |
| # | |
| # The ESLint rule (`verify-stand-in/no-asserted-driver-argument`) blocks | |
| # the assertion. This step is the half that stops the rule going dead: it | |
| # reconciles the guarded set against what packages/verify actually exports | |
| # in BOTH directions — so a third stand-in check cannot arrive unguarded | |
| # and a stale entry cannot rot — and COUNTS the call sites it reached, so | |
| # a rename or a moved import surfaces as a census that fell instead of as | |
| # 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" | |
| run: pnpm check:verify-stand-in | |
| # Raw control-byte guard (#3127 / #4890 / #5157 / #5460 / #6984). Scans | |
| # every TEXT file git knows about for a raw ASCII control byte and fails on | |
| # any hit. Since #6984 the scan set is the index PLUS untracked-but-not- | |
| # ignored working-tree files, so a locally-authored file is covered before | |
| # it is staged; here that widening is a no-op, because a workflow checks out | |
| # a commit and has no untracked files at all (the step's summary line says | |
| # so — it names both halves, and the untracked one reads 0 in CI). | |
| # WHICH bytes are in the set and WHY each is rejected are stated and argued | |
| # once, in the gate script's header — `scripts/check-nul-bytes.mjs`. That | |
| # header is authoritative and this comment cites it rather than restating it | |
| # (#5579 established the footing, #5681 applied it here). The one-line | |
| # summary, for whoever is reading this because the step just went red: the | |
| # set is every ASCII control character except tab/LF/CR, drawn by the | |
| # ACCIDENT SOURCE — an editing tool materialising an escape into its byte — | |
| # and not by byte semantics, so "mine is not a NUL" is never a reason to read | |
| # a hit as a false positive. | |
| # The command name stays `check:nul-bytes` for continuity — see the script's | |
| # header for why. Authors must write the unicode escape instead of the byte. | |
| - name: Raw control-byte guard | |
| run: pnpm check:nul-bytes | |
| # The three shared modules the two `scripts/**` routing gates below | |
| # DELEGATE their design arguments to (#10608). Both of those gates are | |
| # SPELLING gates by deliberate design — `check:entry-guard` enforces that | |
| # only `scripts/invoked-as.mjs` may read `process.argv[1]`, and | |
| # `check:parse-guard` that every TypeScript parse goes through | |
| # `scripts/ts-parse.mjs` — and each answers "why is spelling enough?" the | |
| # same way: the BEHAVIOUR is pinned once, at the module, by that module's | |
| # own `--self-test`. Neither self-test ran anywhere. So CI enforced | |
| # *"everybody routes through the module"* and never once checked that the | |
| # module still refuses — a regression inside `ts-parse.mjs` (a | |
| # `process.exit` downgraded to a `throw`, a dropped `parseDiagnostics` | |
| # read) was caught by nothing, and its symptom is the same green line this | |
| # whole family exists to distrust (#10574 / #10573 / #4690). | |
| # | |
| # `js-comment-mask.mjs` is here because BOTH gates ask it to tell code | |
| # from prose before they judge anything, and its header records that it | |
| # exists because two private `stripComments` families drifted apart in | |
| # opposite directions. Its two failure directions are not symmetric: a | |
| # mask that stops masking makes both gates fabricate findings out of their | |
| # own documentation — loud — while a mask that starts OVER-masking makes | |
| # both go quietly green over text they never read. | |
| # | |
| # What runs here is the self-tests, not the modules' callers: the callers | |
| # are the two gate steps below, which already run on every PR. | |
| # Invoked as `node` rather than through `pnpm check:*`: see the GATE | |
| # INVOCATION IDIOM note at the top of this file. | |
| # `ts-parse` spawns ~20 real node children (~10 s measured, and the spawns | |
| # are the point — they pin that a caller's try/catch cannot swallow the | |
| # refusal); the other two are in-process fixtures, ~0.5 s combined. | |
| # | |
| # ⭐ Collected rather than sequenced, for the reason spelled out at the | |
| # `Shallow-history guard self-tests` step below (#10814): under `bash -e` a | |
| # bare sequence stops at the first failure, so a red `ts-parse` would leave | |
| # the entry-predicate and comment-mask self-tests UNRUN while the log shows | |
| # only the one failure. `ts-parse` is both the slowest of the three and the | |
| # one that spawns real children, so it is the likeliest to be red — | |
| # precisely the masking direction. The three modules are independent of one | |
| # another, so collecting loses nothing. | |
| - name: scripts/ shared-module self-tests (parse · entry predicate · comment mask) | |
| run: | | |
| # Tolerate-and-collect (#10814) — see the note above this step. Each | |
| # self-test runs unconditionally and prints its own verdict; the step | |
| # still FAILS when any of them does, naming every one that failed. | |
| # ⛔ Never let the collector swallow the exit code — a green step over | |
| # a red self-test looks identical to success. Both directions are pinned | |
| # by `node scripts/check-step-collectors.mjs --self-test`, which extracts | |
| # THIS block from THIS file and drives it under `bash -e` with stubs. | |
| failed="" | |
| run_self_test() { | |
| echo "-- $*" | |
| if "$@"; then | |
| echo "PASS $*" | |
| else | |
| echo "FAIL $*" | |
| failed="${failed} $*"$'\n' | |
| fi | |
| return 0 | |
| } | |
| run_self_test node scripts/ts-parse.mjs --self-test | |
| run_self_test node scripts/invoked-as.mjs --self-test | |
| run_self_test node scripts/js-comment-mask.mjs --self-test | |
| if [ -n "$failed" ]; then | |
| echo "" | |
| echo "scripts/ shared-module self-tests — the following FAILED:" | |
| printf "%s" "$failed" | |
| exit 1 | |
| fi | |
| echo "scripts/ shared-module self-tests — all three ran and passed" | |
| # Every `scripts/**` entry guard goes through ONE predicate (#10086). | |
| # The hand-typed forms of "did node run me, or did someone import me?" | |
| # had drifted into ELEVEN spellings across 33 files, and NINE were wrong | |
| # in the same invisible direction: node resolves symlinks for the module | |
| # graph but leaves `process.argv[1]` as the caller typed it, so a script | |
| # reached through a symlink compared two different paths, answered false, | |
| # and did NOTHING — exit 0, no output. The CI wrappers here hold the | |
| # child's exit STATUS only, so an inert child read as a green gate; the | |
| # governed-surface register (`scripts/pm/check-governed-merges.mjs`) was | |
| # among the affected, where the inert run and its "NOT governed, ordinary | |
| # queue landing applies" verdict are the SAME exit code. | |
| # The sweep alone would not have held — nothing stopped a twelfth | |
| # spelling. This gate is the part that closes the class: only | |
| # `scripts/invoked-as.mjs` may read `process.argv[1]`, and that module's | |
| # own self-test drives a real probe through a real symlink. Rationale and | |
| # the rejected behavioural-sweep alternative: the gate script's header. | |
| # Scans ~115 files, no spawns; ~0.2s. | |
| - name: scripts/ entry guards go through one predicate | |
| run: pnpm check:entry-guard | |
| # Every `scripts/**` TypeScript parse goes through ONE module (#10133 / | |
| # #10573), and this is the half that keeps it that way (#10574). | |
| # NONE of the three parser entry points throws on a source it cannot | |
| # read: `ts.createSourceFile` returns a tree built by error recovery with | |
| # the errors parked on `parseDiagnostics`; `ts.createProgram` parks them | |
| # behind a second call, `getSyntacticDiagnostics()`; `ts.transpileModule` | |
| # reports nothing at all without `reportDiagnostics: true` and still | |
| # hands back an `outputText`. A gate then walks the wreckage, finds none | |
| # of the shapes it is looking for, and scores the file CLEAN — so the | |
| # SYMPTOM OF A MISSING REFUSAL IS A GREEN LINE, and an unguarded gate is | |
| # indistinguishable from a guarded one by reading CI. That is not a | |
| # theory: one gate here forced `ScriptKind.TSX` on 2504 test files, read | |
| # 32 of them as wreckage, and printed `OK` while six pinned engine | |
| # doubles went uncounted. | |
| # The #10573 sweep converted 32 call sites across 15 gates; it could not | |
| # stop the sixteenth being typed, and within the hour a new gate landed | |
| # with two raw calls in it — caught by this step, which is the whole | |
| # argument for having it. Same shape as `check:entry-guard` above. | |
| # Also prints the parses OUTSIDE `scripts/**` (#10575) that it does not | |
| # govern, so its green line is read as a claim about `scripts/` and not | |
| # about the repository. | |
| # Scans ~121 scripts/ files plus a read-only census of the rest, no | |
| # spawns; ~0.6s. | |
| - name: scripts/ TypeScript parses go through one module | |
| run: pnpm check:parse-guard | |
| # Nobody writes a NEW private comment-stripper (#12307). | |
| # `scripts/js-comment-mask.mjs` exists because two private `stripComments` | |
| # families drifted apart in opposite directions — one regex-based and | |
| # blind to string literals, opening PHANTOM comments that delete real | |
| # code; one string-aware but regex-blind, hiding real comments. What the | |
| # tree had no instrument for was ADOPTION: `check:comment-mask-corpus` | |
| # below verifies the shared mask against a real parser, which is a claim | |
| # about the MODULE and says nothing about its callers. So the module | |
| # landed, some callers were converted by hand, and the residue was found | |
| # by hand three separate times — three cards for one conversion. | |
| # This gate is the half that covers the caller nobody has written yet: | |
| # a new private stripper reds on the PR that writes it, while the 23 | |
| # measured pre-existing ones sit in a shrink-only ledger and are NOT | |
| # required to move first (a gate that reds on day one is a gate nobody | |
| # can land). Converting them is per-row and deliberately not this gate's | |
| # call. | |
| # ⚠️ NOT a widening of `check:parse-guard` above: that gate governs the | |
| # three TypeScript PARSER ENTRY POINTS and its own header refuses a root | |
| # broader than `scripts/**` twice. Comment-stripping is a different | |
| # subject, and widening that population would still catch no stripper. | |
| # Its `--self-test` runs FIRST and is where the detectors are observed | |
| # FIRING and, just as importantly, observed STAYING SILENT on an adopter | |
| # and on prose — the live corpus is green by construction, so a passing | |
| # production run alone cannot tell a working detector from a dead one. | |
| # The ledger's stale-row equality is the other half: breaking a detector | |
| # reddens the production run (measured: 3 stale rows) instead of going | |
| # quietly green, which is what keeps this out of the double-green family | |
| # `check:self-test-wired` names. | |
| # Invoked as `node` rather than through a `pnpm check:*` alias: see the | |
| # 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) | |
| run: | | |
| node scripts/check-comment-mask-adoption.mjs --self-test | |
| node scripts/check-comment-mask-adoption.mjs | |
| # A package's published `src/**` may only import workspace packages its own | |
| # manifest DECLARES (#10062). The class was filed with one member and a | |
| # mitigation — "type-only, so nothing lands in the emitted JavaScript" — | |
| # and grew to four with no signal, one of them a VALUE import whose | |
| # implementation the bundler then INLINED into a second package's dist. | |
| # Both halves of the mitigation are now ledger EVIDENCE instead of prose: | |
| # a `type-only` row reds the moment its import stops being type-only. | |
| # Node builtins plus the shared comment mask only — no node_modules, so a | |
| # reviewer can run it in place. Its `--self-test` runs FIRST: the | |
| # production run over a fixed tree is green by construction, so it cannot | |
| # tell a working matcher from a dead one, and the specifier FLOOR is the | |
| # other half (a dead matcher finds nothing, and nothing is what success | |
| # looks like — so it refuses with exit 2 rather than passing). | |
| # Invoked as `node` rather than through a `pnpm check:*` alias: see the | |
| # 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 | |
| run: | | |
| node scripts/check-undeclared-dep-imports.mjs --self-test | |
| node scripts/check-undeclared-dep-imports.mjs | |
| # A text-family column a DECLARED INDEX keys on must declare a `maxLength` | |
| # (#12147, route A of #11374). Without one `driver-sql` emits it TEXT, MySQL | |
| # refuses `ALTER TABLE ... ADD INDEX` with ER_BLOB_KEY_WITHOUT_LENGTH, and the | |
| # object lands REGISTERED-BUT-BROKEN with its declared index silently absent | |
| # (measured live on MySQL 8.0.46, #12058: 12 of 44 platform objects, sys_session | |
| # among them). Enforcement used to be per-package pins, and each one was widened | |
| # by a column that had escaped the previous scope -- objects keep moving across | |
| # package boundaries under ADR-0029 K2, so a boundary-scoped pin re-opens the | |
| # hole every time one moves. A central importing pin is NOT available: measured | |
| # on PR #12143, it would invert the dependency graph. So this is a class-level | |
| # source scan over every `*.object.ts`. | |
| # Node builtins plus the shared comment mask only -- no node_modules, so a | |
| # reviewer can run it in place. Its `--self-test` runs FIRST, and that leg is | |
| # the load-bearing one: the production run over a fixed tree is green by | |
| # construction, so it cannot tell a working matcher from a dead one. The other | |
| # half is the FLOORS -- a sweep that finds nothing because it swept nothing | |
| # reports exactly what a clean tree reports, so an empty population is `exit 2` | |
| # rather than a pass. Unclassifiable shapes refuse for the same reason. | |
| # Invoked as `node` rather than through a `pnpm check:*` alias: see the | |
| # GATE INVOCATION IDIOM note at the top of this file. | |
| # Scans 113 *.object.ts files, no spawns; ~0.3s. | |
| - name: Keyed text-family columns declare their bound (#12147) | |
| run: | | |
| node scripts/check-keyed-text-bounds.mjs --self-test | |
| node scripts/check-keyed-text-bounds.mjs | |
| # The bash-3.2 floor, over every shell file the repo ships (#12221). | |
| # `/usr/bin/env bash` is bash 3.2.57 on macOS -- Apple ships no bash 4+, | |
| # for licensing reasons -- and THIS RUNNER IS BASH 5, where every construct | |
| # the gate refuses works perfectly. So the class is invisible to a normal | |
| # green run in both directions: the defect passes CI, and so does its | |
| # repair. It is found by an operator on a Mac, at status 127, at the moment | |
| # they most needed the script to work. Two incidents, four sites, both | |
| # caught by hand and late; one of them turned the shared verify lock's | |
| # bounded wait into an unbounded spin, because an unbound `EPOCHSECONDS` | |
| # leaves the deadline EMPTY rather than erroring. | |
| # What this step buys that a bash-5 runner cannot otherwise see is a static | |
| # verdict: the gate reads the text, so it never needs the construct to be | |
| # reachable, only written. Rationale, the construct table with a bash | |
| # version per row, and the three exemption rules that tell a file HUNTING | |
| # these tokens from a file USING one: the gate script's header. The short | |
| # version, for whoever is reading this because the step just went red -- | |
| # there is deliberately NO filename allowlist, and no waiver comment; a | |
| # legitimate mention is already exempt by being a full-line comment, a | |
| # guarded `${VAR:-}` read, or a token outside command position. | |
| # ⛔ This does not supersede the two file-scoped scans in | |
| # `check:objectui-changeset` and the verify lock's own self-test. Those | |
| # pair their scan with a SIMULATED 3.2 run (`enable -n mapfile readarray` | |
| # via BASH_ENV, plus `unset` of the bash-5 variables), which reaches | |
| # runtime-assembled commands a static scan cannot see and proves the real | |
| # path COMPLETES without the builtins. Different instruments, one class. | |
| # Scans 20 tracked shell files, no spawns in the production run; ~0.1s. | |
| # The `--self-test` half spawns a handful of real bash children (the | |
| # known-bad fixture tree, and the simulated-3.2 probe that proves the | |
| # harness is not vacuous); ~1s. | |
| - name: shell scripts hold the bash 3.2 floor | |
| run: pnpm check:bash32-floor | |
| # The comment-mask CORPUS sweep (#10640) — the other instrument for the | |
| # module the two gates above delegate "is this span a comment, or code?" | |
| # to. `js-comment-mask.mjs --self-test` pins the SHAPES someone wrote | |
| # down; this parses every .{ts,tsx,mts,cts,js,mjs,cjs,jsx} file in the | |
| # tree with @typescript-eslint/parser and diffs its comment ranges | |
| # against the mask's, byte for byte. Neither subsumes the other, and that | |
| # is measured in both directions on this tree rather than assumed: | |
| # deleting the `{` counting inside `${...}` fails a pinned case and the | |
| # sweep reads 0 disagreements over 4,741 files (the tree does not write | |
| # that shape), while dropping `return` from REGEX_AFTER_KEYWORD passes all | |
| # 23 pinned cases and the sweep names check-test-source-alias.mjs, where | |
| # `return /(^|[^a-z])dist\//` is written today. | |
| # The defect this would have caught shipped: 16 files disagreeing, 15 of | |
| # them reading commented-out text as live code, 10,252 comment bytes in | |
| # one file — and the instrument that found it lived in an agent's | |
| # scratchpad, which is why re-deriving it was impossible until this file. | |
| # WHY A HARD GATE ON EVERY PR, and not an on-demand flag: the sweep's | |
| # verdict depends on the CORPUS as much as on the masker, so a PR that | |
| # writes a shape the mask reads wrong turns ~20 source-scanning gates | |
| # into liars on that file, on a PR that never touched the masker. That | |
| # arrival is not detectable by any convention a human or an agent has to | |
| # remember at the right moment — the failure mode this repo has already | |
| # paid for at #4690 and again here. | |
| # WHY A STEP AND NOT A JOB: a new job's `name:` is a required-context | |
| # contract (scripts/check-required-contexts.mjs) that only a | |
| # maintainer-present Settings change can enrol, and an unenrolled job is | |
| # an advisory gate — #5617 verbatim. This job is already required. | |
| # Invoked as `node` rather than `pnpm check:*`, same as the other | |
| # `node scripts/…` steps in this lane: see the GATE INVOCATION IDIOM note | |
| # at the top of this file. | |
| # Runs its own comparator self-test first — 12 cases proving the | |
| # comparison can still REPORT, because "0 files disagree" and "the | |
| # comparison is broken" otherwise print the same line. | |
| # Cost, measured on a contended 4-vCPU container: 4,741 files, 72 MB of | |
| # source, 45–48 s, of which the parser is 45 s. The step prints CI's own | |
| # number on every run. | |
| - name: Comment mask agrees with a real parser over the whole corpus | |
| run: node scripts/check-comment-mask-corpus.mjs | |
| # Stack-collection enumerations vs the schema (#6242). `stack.zod.ts` | |
| # decides which collections a stack may declare; eight other enumerations | |
| # of that same set are hand-maintained (the map-format list, the | |
| # plural→singular map, the artifact category enum, ObjectQL's two | |
| # registration loops, the artifact-ingest field map, the runtime's | |
| # app-payload probe, the showcase coverage manifest) and nothing compared | |
| # any of them to the schema. Each drift instance has been fixed one line at | |
| # 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 | |
| run: pnpm check:stack-collection-maps | |
| # pm-dispatch SKILL.md line ratchet (#7341 / #5925 item 7): the PM skill | |
| # is read in full by every seat session and every Routine fire, so its | |
| # size is a per-round tax on the whole fleet. The #7341 extraction moved | |
| # long incident narratives to .claude/skills/pm-dispatch/references/ | |
| # (append-only case law, loaded on demand); this shrink-only ceiling | |
| # keeps them from growing back. Ceiling discipline — lower freely, raise | |
| # 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 | |
| run: pnpm check:pm-skill-ratchet | |
| # pm-skill issue-ID lint (maintainer ruling 2026-08-12): operative | |
| # agent-protocol text (.claude/skills/pm-dispatch/** and | |
| # .claude/agents/os-dev.md) carries lessons self-contained — no issue-ID | |
| # citations; rulings keep date + verbatim quote. The script header is | |
| # authoritative, including the one self-expiring legacy waiver. | |
| - name: PM skill issue-ID lint | |
| run: pnpm check:pm-skill-id-lint | |
| # PM label description cap. GitHub hard-caps label descriptions at 100 | |
| # CHARACTERS and `gh label create` 422s above it; the `|| true` that makes | |
| # scripts/pm/ensure-pm-labels.sh rerunnable swallows that 422, so an | |
| # over-long -d means the label is never created on a repo that lacks it | |
| # and a rerun can never repair it. Headroom is thin (one description sits | |
| # at exactly 100), and the only previous enforcement was the author | |
| # counting by hand. Measured in characters, never bytes: the live | |
| # needs:contract-review description is 97 characters / 101 bytes, so a | |
| # byte-length guard would red a label GitHub accepts. Unconditional, like | |
| # the other self-tested gates above. | |
| - name: PM label description cap | |
| run: pnpm check:pm-label-desc-cap | |
| # PM dispatch-gates self-test (#8162). `scripts/pm/dispatch-gates.mjs` | |
| # derives the "local gates for this card" line of every dispatch prompt, | |
| # and carried a 61-case --self-test that NO job ran: it executed only when | |
| # a human or an agent typed it. A break in the extraction functions landed | |
| # green and surfaced later as a prompt naming the wrong gate families — | |
| # output that reads as correct, produced by the one tool whose purpose is | |
| # to stop gate lists being memory-shaped. Same family as the | |
| # `Changeset-family gate self-tests` step above, and like that one this | |
| # step is deliberately UNCONDITIONAL: no `if:`, no label read, no paths | |
| # filter. An exemption is what a self-test must not have, or the gap moves. | |
| # | |
| # The gate runs the SELF-TEST only. The live derivation | |
| # (`node scripts/pm/dispatch-gates.mjs <path>`) answers a question about a | |
| # card's file surface, is print-only, and exits 0 on any completed run — | |
| # there is no verdict in it for CI to hold, only a slow read of the whole | |
| # workflow tree. Why the gate is its own file rather than pointing | |
| # `check:pm-dispatch-gates` straight at the tool is measured and argued in | |
| # `scripts/pm/check-dispatch-gates.mjs`'s header (the tool's self-test | |
| # fixtures are path strings, and they become watch hints that fabricate | |
| # MATCHED leads across most of the tree). | |
| # | |
| # ⛔ This paragraph deliberately carries NO cost figure (#12831). The line | |
| # that used to close it — "Reads two files and walks packages/; ~0.3s" — | |
| # described the 61-case self-test named at the top, and the part that grew | |
| # since is exactly the part it denied: the live-tree cases run the same | |
| # discovery the tool does (every workflow file, and then the source of | |
| # every gate that discovery finds), build temporary git repositories and | |
| # drive `changedPathsFromGit` against them, and sweep the tracked corpus. | |
| # That is not "two files", and it is not a walk of packages/. The estimate | |
| # went with the description rather than being refreshed: a replacement has | |
| # to be read off a real CI run of THIS step, because a reading taken in an | |
| # agent container is a reading of a contended box and not of a runner | |
| # (#12528). A cost nobody re-measures ends up endorsing a decision it has | |
| # no standing to endorse, which is what this one had become. No live | |
| # counts stand here either, for the same reason — they rot the same way, | |
| # and it is the SHAPE of the work, not its size, that the argument needs. | |
| # | |
| # ⛔ Cost was never what makes this step unconditional. The FIRST | |
| # paragraph above is, and it stands without this half: an exemption is | |
| # what a self-test must not have, or the gap moves. Losing the estimate is | |
| # therefore NOT an argument for an `if:`, a label read or a paths filter — | |
| # weakening this gate is a maintainer call, and no reading of this note | |
| # authorises one. | |
| - name: PM dispatch-gates self-test | |
| run: pnpm check:pm-dispatch-gates | |
| # Every ROOT_DIR_WATCH_HINTS declaration stays READABLE BY A TEXT SCANNER | |
| # (#12762). The step above proves the extractor still works; this one | |
| # proves it still has something to extract. `extractWatchHints` reads | |
| # source text, so a declaration rewritten from a literal into a mapped | |
| # expression over the gate's population constant contributes NO hint at | |
| # all while its runtime value is unchanged — every local assertion about | |
| # that value stays green, and the gate silently leaves every dispatch | |
| # brief and scores a quiet green for every card in the tree it walks. | |
| # Measured on this tree: the two ledger self-tests above do NOT catch it | |
| # (a computed declaration with the literal kept in a neighbouring comment | |
| # ran all three of them green), and only one of the fifteen declarations | |
| # carried an own-source pin. The search is scoped to the declaration | |
| # STATEMENT: a whole-file search finds the gate's own hint spelled again | |
| # in a runtime assertion or a comment and stays green on the computed | |
| # 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 | |
| run: pnpm check:watch-hint-literal | |
| # PM bare-root worklist self-test (#10840). The step above proves the | |
| # dispatch derivation still WORKS; this one proves the recorded triage of | |
| # the gates that derivation structurally cannot see is still true of the | |
| # tree. A gate whose population is spelled as a bare single-segment word | |
| # builds no watch hint at all — not a dead hint, not a silent verdict, | |
| # nothing — so it is unnameable by any dispatch brief and leaves no residue | |
| # saying so. The worklist enumerates that species and carries one recorded | |
| # verdict per row; this step fails when a verdict outlives its row (the | |
| # shrink) or when a new gate joins the species unjudged. | |
| # | |
| # ⛔ It runs the SELF-TEST only, never the sweep as a verdict: recognising | |
| # this species needs a heuristic over constant NAMES, and #10705 refused to | |
| # put one on the path that derives every PR's gate list. Nothing in | |
| # `dispatch-gates.mjs` reads the worklist, and no verdict in it reaches a | |
| # dispatch prompt. | |
| # | |
| # Unconditional, for the same reason as the step above: a self-test that | |
| # can be skipped is the gap moving rather than closing. Reads the workflow | |
| # tree and every gate source once; ~0.5s. | |
| - name: PM bare-root worklist self-test | |
| run: node scripts/pm/bare-root-worklist.mjs --self-test | |
| # Part-of/closing-keyword guard self-test (#8476). The guard itself is a | |
| # PR-scoped blocking check in its own workflow — it needs a pull request | |
| # body to judge, which this job does not have — so what runs HERE is its | |
| # self-test, which is the half with a verdict independent of any PR. | |
| # Unconditional for the same reason as the two steps above: a self-test | |
| # that runs only when someone remembers is a check whose coverage is a | |
| # function of who remembered, and the failure it hides is quiet — a break | |
| # in the verdict layer lands green and surfaces later as a card silently | |
| # closed by the sentence written to keep it open. | |
| # | |
| # The self-test also pins the WIRING (the guard workflow still invokes | |
| # the script, still subscribes to `edited`, still passes the body through | |
| # `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 | |
| run: pnpm check:partof-closing-keyword | |
| # Publish-smoke tarball pin-set self-test. The assertion it pins lives on | |
| # the RELEASE path (scripts/publish-smoke-pack.mjs runs only inside the | |
| # packed-tarball smoke), so without this step a regression in it would be | |
| # discovered by a release candidate — which is exactly how the unscoped | |
| # `create-objectstack` hole surfaced: as a red on the operator's own | |
| # 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 | |
| run: pnpm check:publish-smoke-pin | |
| # Single-claim path guard self-test (#9402). Same split as the step above | |
| # and for the same reason: the guard is a PR-scoped blocking check in its | |
| # own workflow, because its question is about OTHER open PRs and needs a | |
| # pull request plus an API read that this job has neither of. What runs | |
| # HERE is the half that needs no PR — the verdict layer, the exit-code | |
| # contract, the short-circuit that makes the gate affordable, and the | |
| # declared path list's own invariants. | |
| # | |
| # That last one is the reason this step is worth its second: the declared | |
| # list IS the key, so the realistic way this gate turns into noise is a | |
| # careless append to it. The self-test rejects an entry with no stated | |
| # reason, rejects a duplicate, and pins that the three measured | |
| # high-collision paths (the lock file, the root manifest, one plugin | |
| # manifest — 33, 15 and 21 concurrent pairs in 300 PRs) stay OUT of it. | |
| # | |
| # It also pins the WIRING (the guard workflow still invokes the script, | |
| # still subscribes to `synchronize` — without which a claim added in a | |
| # second commit is never judged — and still passes the token), so | |
| # unwiring the gate reddens here rather than going quiet. | |
| # Dependency-free, reads two files; ~0.1s. | |
| - name: Single-claim path guard self-test | |
| run: pnpm check:single-claim-paths | |
| # PM half-state sweeper self-test (#8528). `scripts/pm/check-half-states.mjs` | |
| # carried a 79-case --self-test — the H1..H7 predicates, the seat-sticker | |
| # parser, the transport classifier and its measured container classes — | |
| # that NO job ran: it executed only when a human or an agent typed it. | |
| # Fourth member of the same family as the three steps above, and the one | |
| # with a blocking consumer: since #8527 the H7 predicate | |
| # (`h7PartOfWithClosingKeyword` and `stripMarkdownCode` beneath it) is | |
| # imported by the PR-scoped guard whose self-test runs in the step above, | |
| # so a break there now reddens — or silently greens — every PR in the repo. | |
| # That incidental coverage is real but bounded: it pins only the H7 | |
| # behaviours the guard depends on. H1..H6, the seat parser and the whole | |
| # transport classifier had none. Unconditional for the family's reason: an | |
| # exemption is what a self-test must not have, or the gap simply moves. | |
| # | |
| # The gate runs the SELF-TEST only. The live sweep | |
| # (`node scripts/pm/check-half-states.mjs`) reads a shared board over the | |
| # GitHub API, is report-only by design (a completed sweep exits 0 whether | |
| # it found 0 or 40 half-states), and its non-zero exits classify the | |
| # ENVIRONMENT — no token, exhausted quota, unreachable host — which is not | |
| # a verdict about the PR running it. The script's own header argues both | |
| # halves. The self-test is offline: no network, no token, ~0.05s. | |
| # | |
| # Pointed straight at the script, with no gate file in between, unlike | |
| # `check:pm-dispatch-gates` next door — that one needs its own file because | |
| # the tool's fixtures are path strings that become watch hints and | |
| # fabricate MATCHED leads across the tree. Measured here on the current | |
| # tree, under the module-body masking that now blanks comments and | |
| # self-tests before the scan: this script yields exactly ONE hint, the repo | |
| # slug in its API base, which is not a repo path and covers no input path. | |
| # The fixtures are issue-shaped objects and prose, and they sit inside the | |
| # masked self-test. So the pollution that forced a separate file there does | |
| # not exist here, and the direct entry is the same shape as the step above. | |
| - name: PM half-state sweeper self-test | |
| run: pnpm check:pm-half-states | |
| # Governed-merges audit self-test (#9495) — same family, same split as | |
| # the half-state sweeper above: the live sweep enumerates the governed | |
| # merges since a date/ref for the PM round report — report-only by | |
| # design, the human-merge-is-the-review-record regime's detection half | |
| # — while this step runs only the offline self-test (pure predicates + | |
| # replay fixtures; no network, no token), so the predicates cannot rot | |
| # unrun the way an uninvoked self-test does (#4690). | |
| # | |
| # ⛔ WHICH surfaces are governed is deliberately NOT restated here | |
| # (#9840). The register is `GOVERNED_SURFACES` in | |
| # `scripts/pm/check-governed-merges.mjs`, and this comment used to spell | |
| # the five out by hand — a copy nothing read. The register grew three | |
| # times in two days, and the two hand copies a seat DOES act on went | |
| # stale twice (#9395, #9511); those two are pinned by the step below, | |
| # over backtick spans inside an anchored region. A bare YAML comment | |
| # cannot present that shape, so this one points at the register rather | |
| # 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 | |
| run: pnpm check:pm-governed-merges | |
| # Governed-surface PROSE pin (#9525) — the other half of the step above. | |
| # `GOVERNED_SURFACES` is machine-read on every path decision, but the | |
| # sentences that TELL a seat which surfaces are governed are prose, | |
| # duplicated out of the register by hand, in AGENTS.md Prime Directive | |
| # #14 and the PM skill's ACCEPT path-fork. That duplicate went stale | |
| # twice in two days (#9395, #9511) and a human — not a gate — caught it | |
| # both times: #9525 re-ran the whole derived gate union against the stale | |
| # directive and every check came back green, with a positive control | |
| # proving the file IS scanned. This step asserts both directions: the | |
| # prose names every registered surface, and claims no surface the | |
| # register lacks (the direction that manufactures enforcement nobody | |
| # has). Unlike the audit next door it is a real gate, not only a | |
| # self-test — it reads the shipped files — so it runs both, and it is | |
| # deliberately UNCONDITIONAL: no `if:`, no paths filter, because the | |
| # 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 | |
| run: pnpm check:pm-governed-prose | |
| # Release-rehearsal clone preflight self-test (#9555). A local | |
| # `pnpm run version` rehearsal — the prescribed verification route for | |
| # every release-machinery change — HANGS FOREVER in an agent container: | |
| # every clone descends from a shallow checkout, so each `.changeset/*.md` | |
| # resolves to the parentless shallow boundary, and @changesets/git answers | |
| # a parentless add-commit by deepening and retrying in a loop with no | |
| # attempt limit — against a remote that gains it nothing and exits 0 while | |
| # doing so. Measured cost before diagnosis: ~2.5 h over two attempts, | |
| # because full CPU with no output reads as slow progress, not as a hang. | |
| # `scripts/pm/release-rehearsal-clone.mjs` is the refusal that turns that | |
| # into a one-second diagnosis, plus the repair for a throwaway clone. | |
| # | |
| # What runs HERE is the self-test, not the live check: the live check | |
| # judges whatever clone it is pointed at, and a CI checkout's depth is the | |
| # workflow's business rather than a verdict this job can hold. The | |
| # self-test pins BOTH directions on real git fixtures — the trapped shape | |
| # refuses, while a healthy tree AND a merely-shallow one whose changesets | |
| # sit after the boundary pass untouched (the predicate is the parentless | |
| # add-commit, never `--is-shallow-repository` alone) — and it pins the | |
| # WIRING, so unlinking the script from docs/releases-maintenance.md or | |
| # from this step reddens instead of going quiet. Temp-dir fixtures, no | |
| # network, ~1.7 s. | |
| # | |
| # Invoked as `node` rather than through a `pnpm check:*` alias: see the | |
| # GATE INVOCATION IDIOM note at the top of this file. Same shape as | |
| # check-links.yml's ADR-link step. | |
| - name: Release-rehearsal clone preflight self-test | |
| run: node scripts/pm/release-rehearsal-clone.mjs --self-test | |
| # Shallow-history guard self-tests (#9902). Three seat-run tools ask | |
| # WINDOWED history questions (`--since` over a branch), and a shallow | |
| # clone answers those from whatever part of the window is present, exits | |
| # 0, and prints no warning. Agent containers clone shallow, and all three | |
| # run from a seat rather than from CI — every workflow that reads history | |
| # already checks out with `fetch-depth: 0`, which is precisely why the | |
| # exposure survived: CI is not where these run. | |
| # | |
| # Measured in one container on 2026-08-21 (graft floor 2026-06-02): | |
| # `check-governed-merges` rendered `✅ clean window` for a window in which | |
| # GitHub lists 38 commits touching `docs/adr/**` and 13 touching | |
| # `AGENTS.md`; `check-engine-split-ratio --days 90` — the workflow's own | |
| # invocation — printed a 98.1% ADR trigger metric over a denominator 17 | |
| # commits short, one of whose members was the graft boundary commit | |
| # itself (it carries the whole tree, so a truncated window does not just | |
| # lose data points, it fabricates one). | |
| # | |
| # What runs here is the self-tests, not the live tools: the live tools | |
| # judge whatever clone they are pointed at, and a CI checkout's depth is | |
| # the workflow's business, not a verdict this job can hold. Each pins BOTH | |
| # directions on real temp-git fixtures — a window crossing the floor is | |
| # refused, and a still-shallow clone whose floor PREDATES the window is | |
| # answered untouched, because the predicate is the floor rather than | |
| # `--is-shallow-repository` and a guard that refused provably-correct | |
| # answers would only train bypass. | |
| # | |
| # `git-history.mjs` is listed first and deliberately: it is the shared | |
| # predicate all three call, it shipped for #9878 with a self-test that | |
| # until now NOTHING in this repo ran, and an unrun self-test is a phantom | |
| # check — the AGENTS.md rule about `@ts-expect-error` in an uncompiled | |
| # file, wearing a different hat. Invoked as `node`/`bash` rather than | |
| # through `pnpm check:*`: see the GATE INVOCATION IDIOM note at the top of | |
| # this file. Temp-dir fixtures, no network, ~6 s in total. | |
| # | |
| # (`check-governed-merges.mjs`'s own cases run in the | |
| # `pnpm check:pm-governed-merges` step above, which is already its | |
| # self-test.) | |
| # | |
| # ⭐ The three run through a COLLECTOR rather than as a bare sequence | |
| # (#10814). A `run:` block is executed by `bash -e`, so the first non-zero | |
| # exit aborts the STEP and every command after it is never reached — | |
| # neither green nor red, and nothing in the log tells those apart. Not | |
| # hypothetical here: while `git-history.mjs --self-test` was red on `main` | |
| # for ~10 h on 2026-08-21 (#10807), the two self-tests listed after it did | |
| # not execute in CI once — on the step that gates every PR. Both were | |
| # green, so that time the mask hid nothing; the compounding shape is that a | |
| # SECOND regression can land unnoticed while the first is red, and then | |
| # reads as though the fix broke it. #4690 one level up: a partial result | |
| # that reads like a complete one. | |
| # | |
| # These three are INDEPENDENT — none is a precondition for reading the | |
| # next — which is what makes collecting correct HERE and | |
| # abort-on-first-failure correct in this job's many | |
| # `<gate> --self-test` + `<gate>` steps, where the self-test IS the | |
| # precondition for trusting the run after it. The census behind that | |
| # distinction: of 200 `run:` steps in lint.yml + ci.yml, 21 hold two or | |
| # more substantive commands, and exactly two were independent self-tests | |
| # sequenced together — this step and the `scripts/` shared-module step | |
| # above. Every other one is a precondition or a dependency, where the | |
| # abort is the correct semantics. | |
| # | |
| # ⛔ Not one step per self-test: a plain step split does not fix this at | |
| # all — Actions skips a job's remaining steps once a step fails, so the | |
| # mask survives the split verbatim. Restoring it would take an `if:` on | |
| # each gate step, and a condition is a way for a PR to arrange that a gate | |
| # does not run on it (the reason the required-context pin step carries | |
| # none). Both gates that read step structure were checked and would | |
| # TOLERATE a split — `check-shard-attestation` scans ci.yml only, and | |
| # `check-required-contexts` pins job-level properties plus the one | |
| # `check:required-contexts` step — so this is a choice on the merits, | |
| # not a constraint. | |
| - name: Shallow-history guard self-tests | |
| run: | | |
| # Tolerate-and-collect (#10814) — see the note above this step. Each | |
| # self-test runs unconditionally and prints its own verdict; the step | |
| # still FAILS when any of them does, naming every one that failed. | |
| # ⛔ Never let the collector swallow the exit code — a green step over | |
| # a red self-test looks identical to success. Both directions are pinned | |
| # by `node scripts/check-step-collectors.mjs --self-test`, which extracts | |
| # THIS block from THIS file and drives it under `bash -e` with stubs. | |
| failed="" | |
| run_self_test() { | |
| echo "-- $*" | |
| if "$@"; then | |
| echo "PASS $*" | |
| else | |
| echo "FAIL $*" | |
| failed="${failed} $*"$'\n' | |
| fi | |
| return 0 | |
| } | |
| run_self_test node scripts/pm/git-history.mjs --self-test | |
| run_self_test node scripts/check-engine-split-ratio.mjs --self-test | |
| run_self_test bash scripts/collect-release-notes.sh --self-test | |
| if [ -n "$failed" ]; then | |
| echo "" | |
| echo "Shallow-history guard self-tests — the following FAILED:" | |
| printf "%s" "$failed" | |
| exit 1 | |
| fi | |
| echo "Shallow-history guard self-tests — all three ran and passed" | |
| # Step-collector gate (#10814) — the guard over the two steps above, and | |
| # over any step that grows their shape later. It has a static half and a | |
| # dynamic half, and the dynamic one is the load-bearing part: | |
| # | |
| # STATIC: one `run:` block invoking `--self-test` on TWO OR MORE DISTINCT | |
| # scripts must route them through a collector. Distinct scripts testing | |
| # themselves are independent by construction, so there is no reading under | |
| # which a failure in one should skip the others. Deliberately narrow: it | |
| # does NOT flag `<gate> --self-test` + `<gate>`, this job's dominant shape, | |
| # where the abort IS the point (a checker whose self-test failed has no | |
| # verdict worth printing), nor ci.yml's `mkdir -p` / `psql ALTER SYSTEM` | |
| # dependency sequences. Swept when it was written: 343 `run:` steps across | |
| # 26 workflows, exactly 2 matched, both of them above. | |
| # | |
| # DYNAMIC: nothing static can tell a collector that PROPAGATES the exit | |
| # code from one that swallows it, and the swallowing kind is worse than | |
| # the masking it replaces — a green step over a red self-test, which from | |
| # outside is indistinguishable from success. So `--self-test` extracts each | |
| # live block out of THIS file and runs it as `bash -e <file>` — the same | |
| # invocation Actions uses — against stubs with controlled exit codes, and | |
| # reads "did this command run" from the STUB's own side effect rather than | |
| # from the block's output, so the block cannot vouch for itself. Both | |
| # directions are pinned in every position, and the same command list is | |
| # also driven through the PRE-FIX bare sequence, which must mask — 1 of 3 | |
| # commands executing when the first fails, 3 of 3 when none does. A harness | |
| # that cannot reproduce the defect cannot certify the fix. | |
| # | |
| # Invoked as `node scripts/…` rather than through a `pnpm check:*` alias: | |
| # see the GATE INVOCATION IDIOM note at the top of this file, which is | |
| # where the correction this comment used to carry now lives in full. | |
| # Temp-dir fixtures, no network, ~1 s. | |
| - name: Step-collector gate (self-tests that mask each other) | |
| run: | | |
| node scripts/check-step-collectors.mjs --self-test | |
| node scripts/check-step-collectors.mjs | |
| # Self-test wiring gate (#11150) — the guard over the `--self-test` legs | |
| # themselves, in this file and every other workflow. | |
| # | |
| # A gate whose defect class is its MATCHING RULE cannot detect its own | |
| # regression on a clean tree. Green means the finding set is empty; | |
| # weakening the rule can only SHRINK that set; and the empty set is the | |
| # fixed point of shrinking. So the production verdict is byte-identical | |
| # before and after the rule breaks, and `--self-test` — which supplies an | |
| # adversarial input a clean tree by construction does not contain — is the | |
| # ONLY instrument watching it. | |
| # | |
| # Measured rather than argued, one ablation per gate with the mutation | |
| # confirmed on disk before any reading was taken: loosening | |
| # `check-auth-mount-ledger`'s exact `METHOD path` accounting into a | |
| # strict-prefix credit, and deleting the recognizer `check-error-code- | |
| # casing` needed for the lowercase codes that shipped past it, each left | |
| # the production run GREEN and reddened only the self-test. So the | |
| # `<gate> --self-test` + `<gate>` steps throughout this file are not | |
| # ceremony beside the live run — for that family the first line is the | |
| # only line carrying coverage, and deleting it to save CI time would | |
| # remove the coverage while every available signal still said the gate | |
| # works. | |
| # | |
| # What this step holds is the mechanically decidable SUPERSET: every | |
| # script CI runs that ships a `--self-test` must have that self-test run | |
| # by CI too. Membership needs no classifier, and that is the point — | |
| # membership in the family itself is NOT statically decidable. The same | |
| # kind of ablation on `check-dispatcher-error-vocabulary` REDDENS its | |
| # production run, because a row it declares stops being reached and its | |
| # ledger is an equality rather than an emptiness; while a second rule | |
| # inside `check-route-envelope` moves neither run. Two rules in one file | |
| # can sit on opposite sides, so a family enumerated by name would wire the | |
| # wrong set and then read as complete. | |
| # | |
| # Scripts whose self-test CI runs by some route other than the flag (a | |
| # wrapper that spawns another tool's, a gate that runs its own cases on | |
| # every invocation) are a shrink-only ledger inside the script, and every | |
| # row carries evidence that is re-checked on each run rather than | |
| # believed. | |
| # | |
| # ⚠️ No count is written into this comment on purpose: a number in CI | |
| # prose goes stale in silence, which is the same class of defect as the | |
| # card that produced this step. The gate prints its own scope line. | |
| # | |
| # Invoked as `node scripts/…` rather than through a `pnpm check:*` alias: | |
| # see the GATE INVOCATION IDIOM note at the top of this file. Reads | |
| # `scripts/` and `.github/workflows/` off disk, no network, ~1 s. | |
| - name: Self-test wiring gate | |
| run: | | |
| node scripts/check-self-test-wired.mjs --self-test | |
| node scripts/check-self-test-wired.mjs | |
| # Self-test output vs. the runner's command parser (#11886). Sibling of | |
| # the step above and sharing its population: that one asks whether CI RUNS | |
| # each self-test, this one asks what each self-test PRINTS while it does. | |
| # | |
| # The defect it closes was live on every PR in this repo. A line of | |
| # `scripts/pm/ci-failure.mjs`'s self-test prose named a workflow-command | |
| # token literally, inside backticks, as documentation of what that tool | |
| # anchors on. The runner does not know a token is being quoted: it parsed | |
| # it and minted `annotation_level: failure` on a check run whose | |
| # conclusion was SUCCESS. On `d63b01436` that artifact was the ONLY | |
| # annotation the `Lint & Repo Gates` run carried. A failure-level | |
| # annotation on a green required check is a claim that something failed, | |
| # and a repo that mints one on every PR is teaching its readers to skim | |
| # exactly the surface other gates report findings into. | |
| # | |
| # ⚠️ INVISIBLE TO EVERY LOCAL RUN, which is the whole reason it is a gate: | |
| # the text is ordinary prose on disk and in a terminal, and only a runner | |
| # transforms it. It was found by reading one job's annotations through the | |
| # API, not by anything in this file. | |
| # | |
| # Two facts were measured on real runs and the gate's shape follows them: | |
| # the legacy `##[...]` form is parsed ANYWHERE in a printed line (the | |
| # measured token sat at column 18 and was still consumed), while the | |
| # `::...::` form is parsed only at LINE START (two mid-sentence ones print | |
| # on every PR from `check-prerelease-pin-watch` and mint nothing). So the | |
| # first detector is unanchored and the second is anchored — flagging inert | |
| # prose would be a false positive the next author would be right to delete. | |
| # | |
| # It RUNS the self-tests rather than reasoning about their source, because | |
| # "is this token printed?" is only answerable by printing it. A static | |
| # prefilter picks which ones to run — over-selecting freely, since the | |
| # verdict never comes from it — which is what keeps this step at ~14 s | |
| # instead of the ~4 min the whole population costs. | |
| # | |
| # Invoked as `node scripts/…` rather than through a `pnpm check:*` alias: | |
| # see the GATE INVOCATION IDIOM note at the top of this file. Reads | |
| # `scripts/` and `.github/workflows/` off disk and spawns the selected | |
| # self-tests; no network. | |
| - name: Self-test workflow-command gate | |
| run: | | |
| node scripts/check-self-test-workflow-commands.mjs --self-test | |
| node scripts/check-self-test-workflow-commands.mjs | |
| # Verify-lock entry-point self-test (#9661). `scripts/pm/os-verify-lock.sh` | |
| # is the ONE way an agent takes the container's shared heavy-verify lock, | |
| # and it is the enforcement of a rule that used to live only in prose: the | |
| # acquisition budget os-dev.md declares is now unrepresentable above its | |
| # cap, grants are ordered by a ticket file, and every run reports how long | |
| # it held. Measured before it existed, in one container: five live waiters, | |
| # three of them 6x over the declared cap, and the compliant ones were the | |
| # ones not verifying — a convention whose violators win is not a convention. | |
| # | |
| # Nothing in CI *uses* the lock (GitHub runners are one job per machine), | |
| # so this step exists to keep the mechanism honest rather than to serialise | |
| # anything: 48 cases over the budget clamp, ticket liveness (dead pid, | |
| # REUSED pid, over-age), fd hygiene, exit-code passthrough, and real | |
| # multi-process contention — queue timeout, legacy free-hand coexistence in | |
| # both directions, and three staggered waiters acquiring in arrival order. | |
| # | |
| # Since #10289 it also guards the BASH 3.2 FLOOR and the bounded-refusal | |
| # rule, which is why the count moved: `/usr/bin/env bash` is 3.2.57 on | |
| # macOS, and a bash 4+/5+ construct there does not fail loudly — it makes | |
| # the acquisition deadline unreachable and turns the wait into an unbounded | |
| # spin that prints no VERDICT at all. So the suite now scans the | |
| # acquisition path for 4+/5+ constructs, runs a real acquisition with | |
| # EPOCHSECONDS/EPOCHREALTIME unset and mapfile disabled, and asserts that | |
| # an unusable host (no `flock`) and a stopped clock each REFUSE with a | |
| # verdict and a non-zero exit rather than waiting forever. | |
| # | |
| # It runs entirely on a private lock under a temp dir; it never touches | |
| # /tmp/os-heavy-verify.lock, so a runner and an agent container behave the | |
| # same. Unconditional and un-`if:`-ed, like the self-tests above — an | |
| # exemption is what a self-test must not have. No network, ~40 s. | |
| - name: Verify-lock entry-point self-test | |
| run: bash scripts/pm/os-verify-lock.sh --self-test | |
| # ci-failure self-test (#9898). Fifth member of the PM self-test family | |
| # above, and until now the odd one out: `scripts/pm/ci-failure.mjs` — the | |
| # one command from "a check is red" to "here is the failing assertion" — | |
| # shipped a `--self-test` that NO job ran, so it executed only when a | |
| # human or an agent typed it. | |
| # | |
| # This tool is worth the second because the rot it would hide is the exact | |
| # defect its own card was about. The file was found near-complete but | |
| # NEVER RUN LIVE: node 22's `fetch` ignores HTTPS_PROXY, so behind an agent | |
| # container's proxy every read answered 401 and the tool's own transport | |
| # probe reported PREREQUISITE NOT MET (exit 3) — and looked right doing it. | |
| # ⇒ A retrieval tool that has stopped working presents as a tool correctly | |
| # declining to work. Nothing about that shape reads as broken, which is | |
| # what makes an unrun self-test the wrong economy here specifically. | |
| # | |
| # The self-test is also materially larger than when the card was filed: | |
| # #9966/PR #10157 corrected the transport probe (a healthy `/rate_limit` | |
| # was greening containers whose repo-scoped reads are refused — the fourth | |
| # measured container class) and added nine cases, which is why this step | |
| # was deliberately ordered AFTER that fix. Wiring a false green into a | |
| # required job would have pinned it as CI-enforced truth. | |
| # | |
| # Beyond the pure predicates it pins the LIVE WIRING: `resolveStep` is run | |
| # against the real `.github/workflows/` tree, so reshaping the workflows | |
| # until no step name resolves to a `run:` block reddens here rather than | |
| # silently degrading every gate failure from "here is the command" into | |
| # "no substitute available". | |
| # | |
| # Unconditional and un-`if:`-ed, like every self-test above it — an | |
| # exemption is precisely what a self-test must not have, or the gap moves. | |
| # | |
| # NO NETWORK, measured rather than assumed (#9898), because a self-test | |
| # that reached GitHub would put this required context at the mercy of API | |
| # availability — a far worse trade than the rot it prevents. Under | |
| # `strace -f` with full egress available the run makes ZERO `socket()` and | |
| # ZERO `connect()` calls (the live walk, traced identically, makes 3 and | |
| # 3), and it exits 0 inside an empty network namespace where the live walk | |
| # exits 3. The `--self-test` branch is chosen before the transport probe | |
| # and never re-execs, and its two readers are injected. ~0.14 s. | |
| # | |
| # Invoked as `node` rather than through a `pnpm check:*` alias, same as | |
| # the release-rehearsal step above: this card's declared file surface is | |
| # this workflow alone, and dispatch-gates.mjs derives gate families from | |
| # either spelling. | |
| - name: PM ci-failure self-test | |
| run: node scripts/pm/ci-failure.mjs --self-test | |
| # os-regen-merge self-test (#12893). Mirrors the `Verify-lock entry-point | |
| # self-test` step above 1:1 — same directory, same split between the tool | |
| # and its self-test. `scripts/pm/os-regen-merge.sh` is the mechanized merge | |
| # sequence for a branch touching os-regen-driven generated artifacts, and it | |
| # has NO CI path at all: it is run BY HAND inside a feature branch's | |
| # worktree, on a merge that exists only there. Its header says so, and that | |
| # is deliberate. | |
| # | |
| # The SELF-TEST is a different animal from the script it tests. It needs no | |
| # worktree, no remote and no merge — only `git` and a temp dir — and it | |
| # builds five small fixture repos to pin 23 cases: the PER-FILE merge-side | |
| # selection and its per-path notices, the staged-diff sentence, the | |
| # uncommitted-hand-off refusal, and the four pre-existing refusals. A few | |
| # seconds, no network. | |
| # | |
| # Left unwired it rots the way this repo has already recorded and fixed | |
| # twice (#11514, #6008): the next refactor of the script reds nothing. Two | |
| # of the 23 cases are SOURCE SCANS of the script's own text — they assert | |
| # step 2 keeps the non-staging `git restore --source` spelling and never the | |
| # staging `git checkout` one — which is to say they are precisely the rows a | |
| # future edit invalidates silently and precisely the rows no reviewer reads. | |
| # Nothing but this step is an instrument for them. | |
| # | |
| # Unconditional and un-`if:`-ed, like every self-test around it — an | |
| # exemption is precisely what a self-test must not have, or the gap simply | |
| # moves. One `--self-test` per `run:` block, deliberately: the masking shape | |
| # `check-step-collectors.mjs` guards is a block driving TWO OR MORE distinct | |
| # scripts. A discovery collector over `scripts/pm/*.sh --self-test` — which | |
| # would also catch the next such script arriving unwired — is ruled out of | |
| # 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 | |
| run: bash scripts/pm/os-regen-merge.sh --self-test | |
| # Claude hook guard self-tests (#11514, objectstack half of | |
| # objectstack-ai/objectui#5754). `.claude/hooks/` holds the enforcement | |
| # behind the two rules whose violation is most expensive in this repo — | |
| # worktree-first and the stash ban — and each guard ships a hermetic | |
| # matrix asserting its block/allow verdict case by case, with a header | |
| # telling you to re-run it after touching the hook. Nothing ran them: | |
| # `grep -rn 'selftest' .github/workflows/` returned no match when this | |
| # card was filed, so those headers WERE the enforcement. A guard that has | |
| # silently stopped guarding is worse than no guard, because everyone keeps | |
| # behaving as though it works — the stash ban's own receipt is two | |
| # parallel agents losing their in-flight changes to a shared LIFO stack. | |
| # | |
| # Baseline on `main` before this step existed, measured rather than | |
| # assumed: `guard-main-checkout-bash` 121 cases pass, `guard-shared-stash` | |
| # 32 cases pass. Both guards were healthy; this wires the alarm, it does | |
| # not fix an outage. | |
| # | |
| # ⭐ DISCOVERED at run time, never listed. A hard-coded list is this | |
| # card's own defect one level up: add a hook with a matrix tomorrow and it | |
| # silently is not run, and nothing goes red. The glob is the contract, so | |
| # a new `.claude/hooks/**/*.selftest.sh` is picked up with no edit here. | |
| # `find`, not a flat glob, so a matrix in a subdirectory is not a silent | |
| # miss either. | |
| # | |
| # ...and an EMPTY discovery is RED, not green (#4690). A renamed or moved | |
| # directory would otherwise make this step pass by running nothing, which | |
| # is the identical green line the whole self-test family above exists to | |
| # distrust. The count is printed on success so a SHRINKING set is visible | |
| # in the log rather than inferred. | |
| # | |
| # ⭐ Collected rather than sequenced, for the reason spelled out at the | |
| # `Shallow-history guard self-tests` step above (#10814): a `run:` block | |
| # is executed as `bash -e`, so a bare loop would abort at the FIRST red | |
| # matrix and leave the rest unrun — neither green nor red, and nothing in | |
| # the log tells those apart. The matrices are independent by construction | |
| # (each builds its own throwaway fixture), so collecting loses nothing. | |
| # ⚠️ `check:step-collectors` CANNOT see this block — its population is | |
| # `scripts/`|`packages/` paths carrying `--self-test`, and these are | |
| # `.claude/hooks/*.selftest.sh` — so the collector shape here is held by | |
| # review, not by that gate. Do not "simplify" it into a bare loop. | |
| # | |
| # ⛔ This step RUNS the matrices; it does not modify any hook. `.claude/**` | |
| # is governed surface and this workflow is not — that separation is what | |
| # keeps the wiring on the ordinary merge path. | |
| # | |
| # Home: a STEP of this job rather than a job of its own, deliberately. | |
| # `Lint & Repo Gates` is a required status context (pinned by | |
| # `check:required-contexts`); a new job would publish a context that is in | |
| # no ruleset, so a red guard would be advisory and the merge queue would | |
| # not stop for it — #5617 verbatim, which is the exact shape this card | |
| # exists to close. | |
| # | |
| # Hermetic by construction, and measured that way: each matrix builds its | |
| # own git repo and linked worktree under $TMPDIR, and both were re-run | |
| # from a primary checkout, a detached HEAD and a non-repo cwd with | |
| # identical results, so neither this checkout's depth nor its branch is an | |
| # input. Needs `jq` and `git` and nothing else — no pnpm, no node, no | |
| # build, no network (`jq` is in the runner image and is already relied on | |
| # bare by cut-rc.yml and release.yml). ~2 s measured. | |
| - name: Claude hook guard self-tests (worktree-first · stash ban) | |
| run: | | |
| # Tolerate-and-collect (#10814) — see the note above this step. Each | |
| # matrix runs unconditionally and prints its own verdict; the step | |
| # still FAILS when any of them does, naming every one that failed. | |
| # ⛔ Never let the collector swallow the exit code — a green step over | |
| # a red self-test looks identical to success. | |
| mapfile -t selftests < <(find .claude/hooks -type f -name '*.selftest.sh' | sort) | |
| if [ "${#selftests[@]}" -eq 0 ]; then | |
| echo "Claude hook guard self-tests — DISCOVERED NOTHING under .claude/hooks/." | |
| echo "This step verified nothing, which is a failure and not a pass (#4690):" | |
| echo "the matrices were moved, renamed or deleted. Re-point the search above" | |
| echo "rather than deleting the step." | |
| exit 1 | |
| fi | |
| echo "discovered ${#selftests[@]} hook self-test(s):" | |
| printf ' %s\n' "${selftests[@]}" | |
| echo "" | |
| failed="" | |
| run_self_test() { | |
| echo "-- $*" | |
| if "$@"; then | |
| echo "PASS $*" | |
| else | |
| echo "FAIL $*" | |
| failed="${failed} $*"$'\n' | |
| fi | |
| return 0 | |
| } | |
| for selftest in "${selftests[@]}"; do | |
| run_self_test "$selftest" | |
| done | |
| if [ -n "$failed" ]; then | |
| echo "" | |
| echo "Claude hook guard self-tests — the following FAILED:" | |
| printf "%s" "$failed" | |
| exit 1 | |
| fi | |
| echo "" | |
| echo "Claude hook guard self-tests — all ${#selftests[@]} ran and passed" | |
| # Docs/skills authoring guard (#2035 / ADR-0059): TS code blocks in | |
| # Markdown/MDX are not type-checked or ESLinted, so skills/ and | |
| # content/docs/ can drift back to teaching the bare `: Page = {}` literal | |
| # 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 | |
| run: pnpm check:doc-authoring | |
| # Docs frontmatter parses (#10493). Nothing local YAML-parsed these pages. | |
| # The one gate that reads them line by line — `check:doc-anchors` below — | |
| # deliberately BLANKS the frontmatter block ("preserving line count"), | |
| # which is right for heading ids and left the closest thing to a | |
| # frontmatter reader as the thing that erases it. So "every page's | |
| # frontmatter parses" had exactly one owner: the `Build Docs` job in | |
| # ci.yml, a full `next build` on a 30-minute-timeout runner that exists | |
| # only in CI and is path-filtered besides. A page landed whose unquoted | |
| # `description` contained `apis: `, YAML read the colon-plus-space as the | |
| # start of a nested mapping, and that build was what said so. | |
| # | |
| # This reads the corpus with the docs build's OWN extractor regex and the | |
| # same `yaml` parser fumadocs resolves, and types the two keys | |
| # `pageSchema` declares (`title: z.string()` required, | |
| # `description: z.string().optional()` typed when present) — not a schema | |
| # of its own: the shape one character from the original defect PARSES and | |
| # yields an object, and only the type assertion sees it. | |
| # | |
| # Population is the WHOLE corpus, generated `references/**` included, | |
| # because that is what `Build Docs` parses; scoping to the hand-written | |
| # half would leave 214 of 403 pages carrying the gap this closes. Every | |
| # unread state is a refusal rather than a quiet pass — an absent root, a | |
| # tree resolving to zero pages, or a page that cannot be read all exit 1 | |
| # naming what could not be read (#4690 / #7484). | |
| # | |
| # Invoked as `node` rather than through a `pnpm check:*` alias, same as | |
| # the aggregator-roster step below: see the GATE INVOCATION IDIOM note at | |
| # the top of this file. Dependency-free filesystem read, sub-second over | |
| # the whole corpus. | |
| - name: Docs frontmatter parses | |
| run: | | |
| node scripts/check-doc-frontmatter.mjs --self-test | |
| node scripts/check-doc-frontmatter.mjs | |
| # #7484 docs anchors: `lychee.toml` sets `include_fragments = "none"`, so | |
| # the `Check Documentation Links` job resolves a link's FILE and never its | |
| # `#anchor` — measured with the pinned lychee 0.24.2 under the CI argv, a | |
| # link to a heading that does not exist is reported `[200] OK`. So a | |
| # cross-file anchor was a two-sided invariant with no owner: rename the | |
| # heading, leave the inbound link, ship green. This resolves every internal | |
| # `#fragment` against the destination page's real heading ids, computed | |
| # with `github-slugger` — the same package fumadocs-core renders the site | |
| # with, so the gate and the renderer cannot disagree about a slug. | |
| # | |
| # It lives in this job rather than in check-links.yml for two reasons: it | |
| # needs a workspace install (that job is deliberately a checkout plus two | |
| # dependency-free `node` checks), and it is REQUIRED where that lane is | |
| # advisory. The four anchors that were already dead were fixed in the same | |
| # PR, so this ships with no baseline and no allowlist. | |
| - name: Docs anchors resolve to real headings | |
| run: pnpm check:doc-anchors | |
| # #12236 one <h1> per doc page. `DocsTitle` renders the frontmatter `title` | |
| # as the page's <h1> unconditionally (apps/docs/app/[lang]/docs/[[...slug]]/ | |
| # page.tsx), so a body-level `# ` heading compiles to a SECOND one inside | |
| # `DocsBody`. Measured on the dev server at /docs/data-modeling/objects | |
| # before the sweep: two <h1>, both reading "Object Metadata". 195 of the 403 | |
| # pages were in that state, 138 of them with the two <h1> identical. | |
| # | |
| # It lives next to check:doc-anchors because the two share the corpus and | |
| # the heading model — this gate imports `stripFrontmatter` and | |
| # `flattenHeadingText` from that one rather than re-modelling either, which | |
| # is what makes the delete-vs-demote remedy right on the nine pages whose | |
| # two headings differ only by inline code markup. | |
| # | |
| # The gate strips FENCED CODE before looking for a heading, and that is the | |
| # load-bearing half: a naive `^# ` scan reports 205 files, ten of which are | |
| # shell/YAML comments inside working examples. A fence-blind rule does not | |
| # over-report by ten, it instructs the fixer to corrupt ten snippets. | |
| # | |
| # Two subtrees are excluded, each with a named owner (#12249 generated | |
| # reference pages, #12250 release notes) and each self-retiring: the gate | |
| # FAILS with DEAD-EXCLUSION when an excluded tree becomes clean, so the | |
| # carve-outs cannot outlive their reasons. A run that judges zero pages | |
| # fails too (#4690). | |
| - name: One `<h1>` per docs page | |
| run: pnpm check:docs-single-h1 | |
| # #9014 docs redirects: `apps/docs/redirects.mjs` is a 92-entry table of | |
| # PERMANENT (308) redirects, and before this step nothing in the repo read | |
| # it — `dispatch-gates.mjs apps/docs/redirects.mjs` named no family over 99 | |
| # discovered ones, and `git grep -l 'redirects.mjs' -- scripts .github` had | |
| # 0 hits. #8948 found three destinations aiming at pages that do not exist: | |
| # live URLs answering a 308 into a 404, which browsers cache and crawlers | |
| # treat as final. The gate resolves every destination the way Fumadocs | |
| # routes it, requires a wildcard destination's target directory to exist, | |
| # and rejects chains (a destination itself matched by a source), following | |
| # Next's own matching semantics. | |
| # | |
| # ── Lane, decided against the two workflow files rather than from memory ── | |
| # | |
| # NOT `check-links.yml`, the topical neighbour: that lane is advisory by | |
| # maintainer ruling 2026-08-07 and — verified in the file, since this is the | |
| # load-bearing half of the argument — its `on:` block carries only | |
| # `workflow_dispatch` and `pull_request`. No `merge_group`, and its own | |
| # header forbids promoting it without adding one in the same change. A gate | |
| # landed there would not block, which is the whole point of filing #9014. | |
| # | |
| # This job rather than the same workflow's `TypeScript Type Check` job, | |
| # which the card recommended: BOTH are required contexts on lint.yml (see | |
| # REQUIRED_CONTEXTS in scripts/check-required-contexts.mjs) and lint.yml | |
| # carries `merge_group`, so the required/advisory half of the card's | |
| # reasoning holds either way — but the ledger records THIS job as the one | |
| # carrying "the whole check:* gate family", and every other docs guard | |
| # (`check:doc-anchors` above, `check:docs-audit-scope`, `check:role-word`, | |
| # `check:quick-reference-counts`) sits in it. The typecheck job additionally | |
| # runs a full workspace build this dependency-free filesystem check has no | |
| # use for. | |
| # | |
| # Runs its own --self-test first (via the pnpm script). That is not | |
| # ceremony here: the live table has been 100% green since 62b2655d8, so a | |
| # passing run over real data cannot distinguish a working resolver from one | |
| # that resolves everything, and the chain and wildcard limbs have never | |
| # fired against real data at all. The self-test is where all three limbs are | |
| # observed failing. | |
| - name: Docs redirect destinations resolve, and no chains | |
| run: pnpm check:docs-redirects | |
| # The docs site's `app/[lang]/` is a catch-all that matches ANY single | |
| # path segment, and `proxy.ts`'s matcher deliberately excludes dotted | |
| # paths from locale rewriting — so before the guard landed, `/ads.txt`, | |
| # `/security.txt`, `/sitemap_index.xml` and every other dotted | |
| # single-segment URL rendered the full homepage under a 200 (measured on | |
| # the dev server; the request log shows no `proxy.ts:` timing for those | |
| # paths, and a probe printed `lang="ads.txt"`). | |
| # | |
| # It sits here rather than in a test because nothing else can see the | |
| # regression: delete the three-line guard and every page still renders, | |
| # every type still checks, every link still resolves. The only symptom is | |
| # a 200 where a 404 belongs, on URLs no test requests. | |
| # | |
| # Runs its own --self-test first (via the pnpm script). Not ceremony: the | |
| # live tree is green by construction after the fix, so a passing run over | |
| # real data cannot distinguish a working gate from one that approves | |
| # everything. The self-test is where every limb is observed failing, and | |
| # where the proxy condition is observed flipping the requirement off. | |
| - name: Docs locale catch-all rejects non-locale segments | |
| run: pnpm check:docs-locale-catch-all | |
| # #11050 route spellings taught in prose: every /api/v1 wire-path | |
| # literal in the published corpora (content/docs/** minus releases/, | |
| # plus skills/**) is judged against the route ledgers, and a literal | |
| # that matches a ledger row's SHAPE while differing from its SPELLING | |
| # is a finding — the drift class #9180 step ② exposed (two sites kept | |
| # teaching the retired plural, and the sweep that found them found a | |
| # third spelling, `/metadata/objects/...`, that a grep for the plural | |
| # cannot see). #10178's pin covers two named files for one route; this | |
| # is the corpus-wide detector. The script header carries the design and | |
| # its measured false-positive boundaries. | |
| # | |
| # ── ADVISORY, by the #11050 triage ruling (measurement-first) ── | |
| # The population was enumerated before this step was wired: 585 | |
| # literals across 442 files on 2026-08-24, zero findings. The flag | |
| # stays until the maintainer strengthens the gate — flipping is | |
| # deleting `--advisory` below, nothing else. Advisory covers FINDINGS | |
| # only: the self-test, a dead root, an evaporated corpus or ledger and | |
| # the literal-count floors are hard failures in every mode, so this | |
| # step still blocks when the scan itself cannot see (#4690 / #4932). | |
| # A step in this required job rather than a new job: an unenrolled | |
| # job's check-run is advisory with no signal anywhere (#5617), while a | |
| # failing step here is read by everyone — and enforcement later needs | |
| # only the flag flip, not a Settings change. | |
| # Invoked as `node` rather than a `pnpm check:*` alias, same as the | |
| # 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) | |
| run: | | |
| node scripts/check-doc-route-spelling.mjs --self-test | |
| node scripts/check-doc-route-spelling.mjs --advisory | |
| # #10751 react-page `useAdapter()` contracts, swept over the app-showcase | |
| # page modules AND the react-page samples in content/docs. Both traps are | |
| # DROP-SHAPED — an unprefixed `top:` is discarded by the adapter and the | |
| # read runs unbounded, and `.records` off a `QueryResult` is `undefined` | |
| # forever — so nothing throws, nothing warns, and the page renders a | |
| # plausible number either way. | |
| # | |
| # This job rather than the example app's own test suite, decided by | |
| # measurement rather than preference: the guard that came before this | |
| # (#10288) lived in `examples/app-showcase/test/` and swept that app's page | |
| # registry, so the docs samples were invisible to it and the SAME wrong | |
| # read survived a third time in `content/docs/ui/react-pages.mdx`. Giving | |
| # that test the docs corpus needs a `check:cross-package-test-inputs` | |
| # declaration plus turbo input hashing, and both spellings are wrong here: | |
| # `content/docs/**` puts the example app's whole suite on 22 of the last | |
| # 132 commits (against 3 that touch the app itself), which is the cost that | |
| # gate's own roster refuses in those words, and the per-page narrowing it | |
| # prefers instead rebuilds this defect — a list someone must remember to | |
| # extend the day a react sample lands on a second page. A gate in this job | |
| # has no radius to maintain: it runs on every PR over the whole tree. | |
| # | |
| # Its --self-test is where the detectors are observed FIRING. The live | |
| # corpus is green (that is the point), so a passing run over real data | |
| # cannot tell a working scanner from one that finds nothing — and the | |
| # census control, which fails when either half of the population comes | |
| # back empty, is what stops a vacuous green from reading as coverage. | |
| - name: React pages honour the useAdapter() query and result contracts | |
| run: pnpm check:react-page-adapter-contract | |
| # #11576 page discovery vs ground truth: what makes something a SERVED | |
| # page is membership in a bundle's `pages:` array (the kernel reads it in | |
| # `registerMetadataCollections`), not its type annotation — while the | |
| # canonical-envelope gates (#11255, #11480) discover their population by | |
| # export shape. #11480 measured that gap on a live page. This gate makes | |
| # the approximation exact by construction: every identifier in a `pages:` | |
| # 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 | |
| run: pnpm check:page-declaration-shape | |
| # #10830 form-section `name` in docs examples: the docs pages that TEACH | |
| # the i18n-anchor convention were the one surface where nothing enforced | |
| # it. `packages/lint`'s `translation-section-name-missing` walks app | |
| # METADATA — `collectionEntries`, `walkPageComponents`, `viewContainerSites` | |
| # — and never sees an `.mdx` fence, and its severity is `'warning'` besides. | |
| # `{/* os:check */}` fences ARE type-checked against the live spec by | |
| # `packages/spec/scripts/check-skill-examples.ts`, but `name` is | |
| # `.optional()` in the schema — deliberately, and it STAYS (#10709) — so a | |
| # nameless section type-checks CLEAN. `content/docs/ui/forms.mdx`'s first | |
| # block carried an `os:check` marker over a nameless section for months. | |
| # | |
| # A gate rather than a fourth sweep, decided by the card's own history: | |
| # the same population was hand-counted three times and produced three | |
| # numbers — #10579 "3 of 5 over four ui/ pages" (single-line scan), #10709 | |
| # "7 of 15 across 5 pages" (multi-line, but required `{` to start a fresh | |
| # line, so it missed `sections: [{`), PR #10827 "8 of 16 across 5 pages". | |
| # Each pass corrected its predecessor's matcher and introduced a subtler | |
| # version of the same error. | |
| # | |
| # This gate's landing census found the error all three SHARED and none | |
| # noticed: all three scoped to `content/docs/ui/**`, and six more nameless | |
| # literals were sitting in TS fences under `content/docs/concepts/` and | |
| # `content/docs/protocol/objectui/`. The real population is 22 literals | |
| # across 8 pages, not 16 across 5 — a hand-derived SCOPE is defeated the | |
| # same way a hand-derived matcher is, and that is the argument for a | |
| # mechanical one. `CENSUS_ANCHORS` pins one of those out-of-`ui/` pages, so | |
| # re-narrowing the scope REFUSES instead of reporting a clean corpus. | |
| # | |
| # Invoked as `node` rather than through a `pnpm check:*` alias, same as the | |
| # doc-frontmatter and aggregator-roster steps: see the GATE INVOCATION | |
| # IDIOM note at the top of this file. Dependency-free filesystem read, | |
| # sub-second over the whole corpus. | |
| # | |
| # Its --self-test runs FIRST and is where the rule is observed FAILING: | |
| # this PR swept the live corpus, so a passing run over real data cannot | |
| # distinguish a working matcher from one that matches nothing. The verdict | |
| # line prints the POPULATION it judged, and `run()` REFUSES — never exits 0 | |
| # — when the corpus falls below its floors or a census anchor stops | |
| # contributing. A silent zero here would be this gate committing the exact | |
| # defect it exists to catch. | |
| - name: Docs form-section examples carry a `name` | |
| run: | | |
| node scripts/check-docs-section-name.mjs --self-test | |
| node scripts/check-docs-section-name.mjs | |
| # #9632 published-README links: a README in a package's `files` array with | |
| # `private` unset is rendered on npm and on GitHub as well as here, and | |
| # NOTHING read its links. Measured before the gate was written: the lychee | |
| # lane globs `content/**` plus the ROOT README.md/ARCHITECTURE.md, never | |
| # `packages/**/README.md`; `check:doc-anchors` takes the same two | |
| # EXTRA_SOURCES; `check:adr-links` is scoped to docs/adr/; | |
| # `check:published-readme-exports` has exactly the right population but | |
| # reads FENCED CODE BLOCKS ONLY and has no notion of a link. So seven links | |
| # across five published service packages sat spelled `/content/docs/...` — | |
| # a form that resolves on none of the three surfaces — and shipped to npm | |
| # green. An npm tarball outlives any in-repo correction, which is why the | |
| # gate is worth more than the seven fixes that came with it. | |
| # | |
| # Strict, with no baseline: the census that sized it found 149 outbound | |
| # links across all 60 published markdown files, so per-link assertions are | |
| # affordable. It reuses rather than re-derives — the POPULATION comes from | |
| # check-published-readme-exports (`publishedDocs`), the page resolver from | |
| # check-docs-redirects (`pageCandidates`, including its | |
| # directory-without-an-index-is-a-404 subtlety), the heading ids from | |
| # check-doc-anchors (`headingIds`), and the fence/code-span stripping from | |
| # check-adr-links. Two gates deriving "published" separately would disagree | |
| # the first time a `files` array changed, silently, each still green. | |
| # | |
| # This job for the same reason check:docs-redirects is here: it is a | |
| # dependency-free filesystem check and this job carries "the whole check:* | |
| # gate family". Runs its own --self-test first, where all three assertions | |
| # are observed FAILING as well as silent — over the real tree the strict | |
| # assertion has a small population, so a green run cannot by itself | |
| # distinguish a working scanner from one that matches nothing. | |
| - name: Published-README links are followable off the docs site | |
| run: pnpm check:published-readme-links | |
| # #9018 docs image tags: the same "example image tag N majors behind | |
| # packages/cli" staleness was found and hand-fixed TWICE, independently — | |
| # content/docs/deployment/self-hosting.mdx (#8911, PR #8960) and | |
| # docker/README.md (#8961, PR #9016). Two occurrences of an identical drift | |
| # is where a mechanical guard beats fixing the third one by hand. The drift | |
| # is silent by construction: a stale tag is valid Markdown, valid Docker | |
| # syntax, and pulls a real (old) image, so a reader following the docs | |
| # deploys the wrong major and finds out at boot. | |
| # | |
| # The gate compares every CONCRETE version pinned on an enumerated doc | |
| # surface against packages/cli/package.json's version, reporting file:line | |
| # with found vs expected. Surfaces are enumerated, never globbed (#9018 asks | |
| # for this): content/docs/** carries ~30 "removed in @objectstack/spec | |
| # 17.0.0" sentences, historical facts that must NOT track the CLI version. | |
| # | |
| # ⚠️ STANDING OBLIGATION, stated where the next release engineer will look: | |
| # this gate goes RED on every version bump until the doc surfaces move in | |
| # the SAME change. That is the mechanism working, not a false red — the | |
| # remedy is mechanical and the failure names every line to edit. | |
| # | |
| # Same job as the other check:* families, for the reason the #9014 dev | |
| # established from the repo's own source rather than from memory: the | |
| # REQUIRED_CONTEXTS ledger in scripts/check-required-contexts.mjs records | |
| # THIS job's `carries` as "the whole check:* gate family", and every docs | |
| # guard (check:doc-anchors, check:docs-redirects, check:docs-audit-scope, | |
| # check:role-word) already sits here. The typecheck job additionally runs a | |
| # full workspace build this dependency-free filesystem check has no use for. | |
| # | |
| # Runs its own --self-test first (via the pnpm script), and that is the | |
| # load-bearing half: BOTH surfaces the card names were repaired within the | |
| # day before this landed, so the live corpus is guaranteed green and a | |
| # passing run over real data cannot distinguish a working gate from one | |
| # 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 | |
| run: pnpm check:docs-image-tag | |
| # #9064 — the self-test of the VERSION-TIME REWRITER that keeps the gate | |
| # above from ever having to fire. The gate alone could not: `changeset | |
| # version` bumps packages/cli on a release PR that (per | |
| # sync-template-versions.mjs's own header) gets NO CI because | |
| # changesets/action opens it with the default GITHUB_TOKEN — so the bump | |
| # merged green and the gate reddened on the NEXT ordinary PR, naming files | |
| # that author never touched. scripts/sync-docs-image-tags.mjs runs in the | |
| # root `version` script, alongside the two sync scripts already there. | |
| # | |
| # Only the --self-test runs here, and that is the whole point: the rewriter | |
| # itself has nothing to do on a green corpus, so CI can never observe it | |
| # working. The self-test is where a STALE fixture is observed going green | |
| # through the gate's own checkSurfaces, and — the control that matters just | |
| # as much — where a CLEAN fixture is observed left byte-identical and | |
| # unwritten, because an over-eager rewriter would silently corrupt the | |
| # documented tag scheme and historical version prose across three files. | |
| # | |
| # Same job as the gate deliberately: they share one SURFACES/PATTERNS list, | |
| # 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 | |
| run: pnpm check:docs-image-tag-sync | |
| # #9348 — the same treatment for the OTHER version-time rewriter, argued | |
| # there from two measured gaps after #9648 had already added a vitest file | |
| # which runs the CLI. GAP 1 has since closed; it is recorded here rather | |
| # than deleted, so the next reader does not re-derive a conclusion the | |
| # workflow no longer supports. | |
| # | |
| # GAP 1 — SCHEDULING. CLOSED by #9829 / #10014. The original claim: | |
| # `create-objectstack#test` is reachable, at PR time, only from ci.yml's | |
| # `test` job; that job was gated on the `core` paths-filter ALONE; and | |
| # `core` — `packages/**`, `examples/**`, `apps/!(docs)/**`, | |
| # `package.json`, `pnpm-lock.yaml`, `tsconfig.json`, | |
| # `.github/workflows/ci.yml` — matches no path under `scripts/`, so a diff | |
| # confined to scripts/sync-template-versions.mjs skipped Test Core | |
| # ENTIRELY, and the vitest with it. `core` is still false on that diff — it | |
| # was never widened — but the `test` job now ORs in a SECOND filter output, | |
| # `scripts: ['scripts/**']`, which matches, so | |
| # `if: ... (core != 'false' || scripts != 'false')` resolves to RUN. | |
| # Re-measured against the merged filter with picomatch 2.3.1 — the version | |
| # dorny/paths-filter@v4's own package-lock.json resolves, NOT this repo's | |
| # 4.0.5, which is what the old text cited; the two agree on these globs, so | |
| # the wrong figure never produced a wrong verdict — `core=false`, | |
| # `scripts=true`. With the job running the rest of the chain follows: | |
| # `--union-into` is a step inside it, so it runs and unions | |
| # create-objectstack back in off its declared globs; `create-objectstack#test` | |
| # declares | |
| # `$TURBO_ROOT$/scripts/sync-template-versions.mjs` in turbo.json, so the | |
| # task hash moves and no cached green is replayed. PR CI is the first | |
| # signal now, not the queue build one merge later. | |
| # | |
| # Two halves of that argument are UNCHANGED, and they are why `--union-into` | |
| # has to exist at all: `turbo ls --affected` returns ZERO packages for that | |
| # diff (a package-local edit returns 1, so the probe is live), and the | |
| # `$TURBO_ROOT$` entry moves the task HASH, which is what stops a cached | |
| # green — a different thing from scheduling the job. Unchanged too is the | |
| # failure SHAPE the retired sentence pointed at — the merge queue as first | |
| # signal, one merge late, as batch collateral — which | |
| # check-cross-package-test-inputs.mjs's own ledger records twice. It is | |
| # simply no longer what happens to a diff confined to this rewriter. | |
| # | |
| # GAP 2 — THE RED PATHS. Open, and it carries this step alone now. This | |
| # workflow still carries no paths filter and no filter job, so the step | |
| # runs on every pull request, push and merge-queue build, unconditionally, | |
| # where the vitest's scheduling now rides a chain (filter output, then the | |
| # union's declaration, then the task hash). That is robustness; it is no | |
| # longer the reason the flag exists. The reason is that the cases below are | |
| # executed nowhere else. | |
| # | |
| # Only the --self-test runs here, for the same reason as the step above: | |
| # the rewriter has nothing to do on a green corpus. The cases are scoped to | |
| # what the vitest does NOT assert — a CLEAN corpus left byte-identical and | |
| # UNWRITTEN, and every red path (missing stamp, missing file, unparseable | |
| # package.json, a template with no @objectstack/* dependency, the empty | |
| # templates directory) observed exiting 1 and naming the file. The STALE -> | |
| # rewritten direction and the discovery walk stay in | |
| # 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 | |
| run: pnpm check:template-version-sync | |
| # #4851: the docs-accuracy-audit workflow carries its default scope inline | |
| # (a workflow script runs in a vm with no filesystem, so it cannot enumerate | |
| # content/docs/ itself). Hand-kept, that list rotted in BOTH directions — | |
| # 16 entries pointing at files that no longer existed after the | |
| # protocol/objectos → protocol/kernel rename, and 48 existing docs missing | |
| # from it — while every "FULL audit" run reported green, which is how the | |
| # accuracy defects in #4781 and #4817 survived ~2 months. This regenerates | |
| # the list from the filesystem and fails when the two disagree either way. | |
| # It lives in this job deliberately: the change that breaks the 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 | |
| run: pnpm check:docs-audit-scope | |
| # ADR-0090 D3 vocabulary ratchet: "role" is reserved-forbidden in docs | |
| # and skills. Existing occurrences are frozen in the baseline (better-auth | |
| # boundary, ARIA samples, educational mentions); NEW occurrences fail. | |
| # Improvements ratchet the baseline down via --update. | |
| - name: Reserved-word ("role") docs ratchet | |
| run: pnpm check:role-word | |
| # #11671: `os i18n extract --fill=default` fills GAPS only, so revising a | |
| # source string rewrites `en` and strands the previous source text in every | |
| # other locale. The bundle stays in sync BY KEY, so `check:i18n` reports OK | |
| # and `check:i18n-coverage` counts the leaf translated (it tests presence, | |
| # not freshness) — measured on PR #11659, three locales serving a 602-char | |
| # superseded draft of a 411-char help string under 31 green checks. | |
| # This gate reads the committed bundles as TEXT — no workspace build — which | |
| # is why it sits here rather than with its two i18n siblings in `typecheck`. | |
| # 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 | |
| run: pnpm check:i18n-stale-fill | |
| # #6319: content/docs/getting-started/quick-reference.mdx is the protocol | |
| # index, and each "## <Name> Protocol (N schemas)" heading is a DECLARATION | |
| # about the table under it. The page is hand-written (build-docs.ts writes | |
| # only content/docs/references/), so the two drift one edit at a time — | |
| # #6319 found Kernel claiming 17 over a 15-row table because two kernel | |
| # rows had migrated into the Cloud section and only Cloud's heading was | |
| # updated. Nothing breaks at runtime; the cost is that content/docs/ is the | |
| # corpus humans and AIs copy from, so a miscounted index is read as a fact | |
| # about the schema catalog. Declared = enforced. | |
| # It lives in this job with the other docs guards: the change that breaks | |
| # 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 | |
| run: pnpm check:quick-reference-counts | |
| # #11752: the "Overlay whitelist (shared-DB tenancy invariant)" table in | |
| # metadata-lifecycle.mdx is a hand-kept copy of DEFAULT_METADATA_TYPE_REGISTRY | |
| # sitting directly under the sentence that calls the registry the single | |
| # machine-readable source. It drifted on FOUR types (flow/permission/position | |
| # said ✅ against a registry that says ❌ after the #6283/#6483 rollbacks; | |
| # `translation` is ✅ in the registry and had no row at all) and was caught by | |
| # a human fact-checking a promo video (#11664), not by CI. The gate compares | |
| # both directions — a table→registry check alone passes a table missing a | |
| # whole row, which is exactly how `translation` survived — and reads the | |
| # registry by AST: the same-line regex sees 26 of the 27 entries, because | |
| # `datasource` opens its brace on its own line. | |
| # It lives in this job with the other docs guards: the edit that breaks it is | |
| # 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 | |
| run: pnpm check:overlay-whitelist-table | |
| # #9604: the runtime-services chapter's pages are enumerated in three | |
| # hand-written places (meta.json "pages", the chapter list in | |
| # runtime-services/index.mdx, the services.* table in kernel/index.mdx) and | |
| # nothing read any of them -- check:docs-audit-scope derives WHICH pages the | |
| # accuracy audit covers, never whether an index enumerates them. #9604 found | |
| # services.sms with a page, a meta.json entry, a registered slot and a | |
| # canonical-source row, missing from BOTH index lists; #9588 was the same | |
| # page drifting on a different line. The pages on disk are the source of | |
| # truth and the gate holds all three enumerations (and the chapter list's | |
| # order) to them. It lives in this job with the other docs guards: the edit | |
| # that breaks it is a docs edit, so a packages/** paths filter would blind | |
| # it to its own failure mode. | |
| # | |
| # #9630 added a fourth claim: each page names the registry slot | |
| # ctx.getService() really resolves it by. #9684 added a FIFTH enumeration | |
| # of the same chapter -- versioning.mdx's "Current Matrix" shipped seven | |
| # rows for eight pages -- so the gate now holds that matrix to the pages | |
| # too, and holds every stability LABEL it and the kernel/index.mdx table | |
| # publish to the page's own Stability bullet. That second half catches a | |
| # WRONG row, which membership checking passes silently. | |
| # | |
| # #9629 closed the last gap. The chapter's "Source of Truth" canonical-source | |
| # list -- a SIXTH enumeration -- was deliberately left unheld while | |
| # `services.security` was an open product question: it carried a row for a | |
| # service with no page, and any rule written over the list would have | |
| # answered that question in a gate rather than by ruling. The 2026-08-18 | |
| # maintainer ruling made the accessor internal, the row is dropped, and the | |
| # list is now held to the pages too -- one row per page, labelled with the | |
| # accessor, each path a file that exists. Row ORDER is deliberately still | |
| # not held: unlike the chapter list and the matrix, this list has never | |
| # followed meta.json nav order. | |
| - name: Runtime-services indexes enumerate the chapter's real pages | |
| run: pnpm check:runtime-services-index | |
| # #10738 generalises the gate above to every section landing page. The same | |
| # defect shape kept recurring one directory over: a section's meta.json is | |
| # what fumadocs builds the sidebar and route order from, the landing page's | |
| # "## What's in this module" block is the reader's map of the same set, and | |
| # nothing read the two against each other — #9604 (runtime-services, sms), | |
| # #10339 (permissions named 14 of 21), #10737 (ai omitted connect-mcp, its | |
| # meta.json's FIRST content page, linked nowhere else on the page). Three | |
| # instances, three humans noticing, three cards. | |
| # | |
| # The heading is the opt-in, NOT the rendering shape. #10738 had set the | |
| # `Cards`-grid sections aside as "a different object" and then had to | |
| # correct itself when api/index.mdx turned out to carry both shapes. Shape | |
| # is simply the wrong discriminator: writing the heading is the author | |
| # declaring "this block is the index of this module". Eight sections | |
| # declare it — four bullet lists (ai, api, permissions, plugins), four | |
| # `Card` grids (automation, data-modeling, kernel, ui) — and the gate reads | |
| # both spellings identically. The other 26 landing pages under content/docs | |
| # are untouched and cannot be caught by accident; they would have to write | |
| # the heading first. That is what keeps the deliberately curated ones | |
| # curated: protocol/objectui is a "For Implementers" reading list mixing in | |
| # /docs/references/ links, and getting-started's "Next Steps" points OUT of | |
| # its module on purpose. | |
| # | |
| # Hrefs and their order only. Link text, glosses, bolding and `Card` | |
| # descriptions are the page's business — meta.json stores SLUGS ONLY, so a | |
| # generated block would have to synthesize link text from frontmatter, and | |
| # that regresses a row that exists today (permissions/access-recipes.mdx is | |
| # titled "Who can see data / automation / interface" and would render as | |
| # that instead of the curated "Access Recipes"). Check them, don't generate | |
| # them — the #9604 precedent, re-measured. | |
| # | |
| # Invoked as `node scripts/…` rather than through a `pnpm check:*` alias, | |
| # same as the other direct steps in this lane: see the GATE INVOCATION | |
| # IDIOM note at the top of this file. Precedent: PR #10698. | |
| # | |
| # Runs its own --self-test first, and that leg is load-bearing: this gate | |
| # computes its own population, so a block parser that stopped matching | |
| # would print a confident green over a tree it never read (#4690's family). | |
| # The self-test drives the real judge()/run() path and pins all seven | |
| # refusals alongside the positive cases, so "refuses unconditionally" | |
| # cannot satisfy it. It lives in this job with the other docs guards: the | |
| # edit that breaks it is a docs edit, so a packages/** paths filter would | |
| # 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 | |
| run: | | |
| node scripts/check-section-landing-index.mjs --self-test | |
| node scripts/check-section-landing-index.mjs | |
| # #3723 ADR anchors: code an accepted ADR governs must keep naming it. | |
| # That incident reversed three accepted ADRs with a patch-level changeset, | |
| # and the mechanism was simply that the edited file never mentioned them — | |
| # so the author could not have known. Presence check only; the invariant | |
| # travels in the failure message (scripts/adr-anchors/, one file per anchor since #6957). | |
| # | |
| # Since #5992 the same script also audits the premise the anchors rest on: | |
| # an ADR number names exactly ONE decision. Three numbers were each claimed | |
| # by two unrelated records (0010/0019/0057) — grandfathered on an explicit | |
| # shrink-only allowlist in the script, so a FOURTH collision goes red here. | |
| # The `--self-test` in the pnpm script provokes that red path on synthetic | |
| # directories every run, because a collision is rare enough that nobody | |
| # would otherwise see the gate fail before it mattered. | |
| - name: ADR anchors + number uniqueness (governed code names its decision) | |
| run: pnpm check:adr-anchors | |
| # NOTE: the standing platform test checklist (docs/qa/platform-checklist/) | |
| # is validated by `pnpm check:platform-checklist`, but by MAINTAINER | |
| # DECISION it is NOT wired into CI — it runs on a periodic manual cadence | |
| # (before a release / after a large platform surface lands), not on every | |
| # PR. The checklist is a QA ledger, not a code gate; keeping it out of the | |
| # per-PR path means an unrelated PR is never blocked by checklist drift. | |
| # Run it by hand: `pnpm check:platform-checklist`. See that dir's README. | |
| # #3280/#3290 org-identifier guard: `organizationId` is the blessed | |
| # developer-facing name for the caller's active org in hook/action bodies; | |
| # the `session.tenantId` alias was REMOVED in v16 (#3290). Keeps our own | |
| # reference code (examples/, apps/, AND packages/) — which authors and AIs | |
| # copy from — off the removed name. Hard-fail (surfaces carry zero | |
| # occurrences today); tests, comments, skills/ and docs/ are excluded, and | |
| # driver-layer `execCtx.tenantId` is never matched. | |
| - name: Org-identifier authoring guard | |
| run: pnpm check:org-identifier | |
| # Authorization resolution must stay single-sourced (resolveAuthzContext, | |
| # @objectstack/core). Guards against a duplicate resolver copy drifting on a | |
| # security path (the REST-vs-dispatcher sys_user_role drift) and against an | |
| # entry point silently dropping the delegation. | |
| - name: Single authz resolver guard | |
| run: pnpm check:authz-resolver | |
| # #11352. Product code may not read a test-RUNNER variable (`TEST`, | |
| # `VITEST*`, `JEST_WORKER_ID`). Runner variables are INHERITED by every | |
| # process the runner spawns, so a product decision keyed off one is made | |
| # for spawned SERVERS too: `local-crypto-provider.ts` selected its crypto | |
| # posture with `if (env.VITEST || ...)`, and `'test'` posture is the branch | |
| # that never refuses to boot without a stable key. A real `os serve` | |
| # spawned from a vitest worker therefore ran with production auth and test | |
| # crypto — invisibly, because a gate that does not run prints nothing. | |
| # `NODE_ENV` is deliberately NOT banned: it describes the deployment. | |
| # Dependency-free source scan over `**/src/**`, comments and literals | |
| # masked; runs its own --self-test first. | |
| - name: Runner-env posture guard | |
| run: pnpm check:runner-env-posture | |
| # #11341, the harness half of the same class the guard above closes in | |
| # product source. `check:runner-env-posture` keeps `src/` from READING a | |
| # runner variable; this one keeps `packages/cli/test/**` from HANDING the | |
| # whole runner environment to a child. A child built from | |
| # `{ ...process.env }` inherits the vitest worker's `TEST=true` and the | |
| # `VITEST*` family, which is how better-auth's origin/CSRF validation got | |
| # switched off inside a spawned `os serve` (#11267) and how the settings | |
| # crypto provider took its never-refuses posture (#11352). Neither showed | |
| # up as a red test: the failure mode is a security assertion that cannot | |
| # go red for the reason it exists, which reads as coverage. | |
| # `childEnv()` in `test/helpers/serve-process.ts` is the choke point and | |
| # was already a CONVENTION; this is what makes it an invariant. | |
| # PRECISION over reach, deliberately: only files that actually import a | |
| # process-creating API are scanned, and reading ONE variable off the | |
| # environment (`process.env.HOME`) is never flagged — a gate that flagged | |
| # every `...process.env` in the package would be carved out into | |
| # uselessness. Measured when it landed: 82 sources, 28 spawners, and zero | |
| # bulk copies outside a spawner file, so the filter costs no recall. | |
| # The 18 leaks that predate the gate are baselined shrink-only in | |
| # scripts/cli-test-child-env.baseline.json, each awaiting a card per | |
| # neighbourhood; the ratchet refuses new members outright. The two sites | |
| # that copy the environment ON PURPOSE — the choke point, and #11267's pin | |
| # leg that proves the leak does something — are DELIBERATE entries pinned | |
| # in BOTH directions, so "cleaning up" the evidence fails here. | |
| # Runs its own --self-test first, and that leg is not decoration: this | |
| # gate computes its own population, so a scan that stopped matching would | |
| # print a confident green over a tree it never read (#4690's family). Its | |
| # positive control runs OUT OF PROCESS through the real entry point, | |
| # 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 | |
| run: pnpm check:cli-test-child-env | |
| # #12016. A CLI command id spelled as a string literal OUTSIDE the CLI | |
| # package has nothing tying it to the command file it names: rename the | |
| # command, update the CLI and the docs, and the engine's own drift warning | |
| # keeps printing a route that no longer exists — a stale hint inside an | |
| # otherwise-correct message, which no suite reads as wrong because | |
| # driver-sql's pin asserts the message contains the constant and the | |
| # constant still matches itself. 274 such literals across 98 files were | |
| # 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 | |
| run: pnpm check:cli-command-ids | |
| # #4093 follow-up. Discovery tells a consumer an absent capability is | |
| # absent AND what to install. The first half has been carefully honest | |
| # since #2462/#4000; the second was invented from the slot name, so ten | |
| # of fifteen entries named packages that do not exist — a dead end handed | |
| # to whoever is trying to fix their stack. Asserts every package named in | |
| # CORE_SERVICE_PROVIDER is a real workspace package, and that no core | |
| # slot is missing an entry. | |
| - name: Service-provider remedy guard | |
| run: pnpm check:service-providers | |
| # #3843 response-envelope guard. The route ledgers audit which routes EXIST; | |
| # nothing audited what comes back, so six route modules emitted bodies | |
| # outside BaseResponseSchema while carrying green `sdk` rows. Counts the | |
| # response write sites per module via the TS AST: when every body goes | |
| # through the module's sendOk/sendError pair that count is fixed, so a NEW | |
| # route hand-rolling a body fails here — coverage a driven test cannot give. | |
| # | |
| # Repo-wide on purpose. The three predecessors were per-package, which is | |
| # structurally unable to notice a module nobody thought to convert; both | |
| # ratcheted modules in the script's table were found by this scan and are | |
| # 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 | |
| run: pnpm check:route-envelope | |
| # Error-code casing guard (ADR-0112, #4003). The ledger's admission test | |
| # enforces casing on every code someone REGISTERS; this catches the ones | |
| # nobody registers — an unregistered lowercase literal in a code position | |
| # is invisible to both the ledger and to the schema on any route that does | |
| # 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 | |
| run: pnpm check:error-code-casing | |
| # #8967: the docs publish an HTTP status per error code and the doors decide | |
| # it; nothing reconciled the two. `MISSING_REQUIRED_FIELD` was documented 400 | |
| # in both pages while five controlled_by_parent refusals answered 422 — on a | |
| # public error contract, with CI green the whole time, found only by a human | |
| # reading a PR. The runtime side is DERIVED (error classes' `readonly status`, | |
| # both `sendError` doors, `HttpStatusErrorCodeMap`), never a second hand-written | |
| # table, and the assertion is a set comparison in BOTH directions so a code may | |
| # legitimately carry more than one documented status. Runs its own --self-test | |
| # first, which pins the real pre-fix doc text as a red case. Placed beside the | |
| # other ADR-0112 error-vocabulary guards, and deliberately in a job with no | |
| # `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 | |
| run: pnpm check:error-status-conformance | |
| # #8087: ADR-0112 closes `error.code`, and the dispatcher door did not | |
| # enforce it — `errorFromThrown` puts the producer's string on the wire | |
| # un-narrowed, and its conformance suite parsed only the cases it drove, | |
| # so three suites pinned bodies `ApiErrorSchema` rejects with CI green. | |
| # Maintainer ruling 2026-08-12: option B, delivered as a GATE rather than | |
| # a sweep — a one-time sweep is a snapshot that decays, and "an unswept | |
| # producer just re-opens the hole". The scan finds every site stamping a | |
| # code the ledger does not know; the declared table | |
| # (packages/runtime/src/dispatcher-error-vocabulary.ts) records the verdict | |
| # WITH its evidence, because reachability is not decidable from source — | |
| # a boot refusal and a live wire code are written identically. Reconciled | |
| # in both directions, so a new producer fails AND a row whose code has | |
| # since been registered fails (that is how the spec half, #8846, ratchets | |
| # the list down). No `paths:` filter, deliberately: the producers live in | |
| # any package, and the registered vocabulary lives in packages/spec. | |
| # Runs its own --self-test first. | |
| - name: Dispatcher error-code vocabulary guard | |
| run: pnpm check:dispatcher-error-vocabulary | |
| # #10534 follow-up 4: a `rawApp` mount under the auth basePath with no ledger | |
| # row. `auth-plugin.ts` mounts routes DIRECTLY on the raw Hono app, ahead of | |
| # the better-auth catch-all, so the vendor's route table cannot account for | |
| # them and `auth.api`'s enumeration -- which the conformance suite drives -- | |
| # cannot see them either. That state produced #9941 and #10050, and the | |
| # #10534 census found NINE of seventeen mounts in neither half of the ledger. | |
| # The pin the suite already carries asserts the objectstack-sourced set | |
| # exactly, so it fails when a row DISAPPEARS; it structurally cannot fail | |
| # when a MOUNT APPEARS, because both of its sides are hand-written. This | |
| # gate supplies that half by enumerating the mounts from SOURCE. Accounting | |
| # is exact on `METHOD path`: matching by substring silently credits a prefix | |
| # route to its longer sibling, which is the artifact that made #10534's own | |
| # census read 5 when the truth was 6. A row alone does not satisfy it -- an | |
| # objectstack row must carry the evidence its disposition claims -- because | |
| # a gate that accepts a pasted row teaches the next author to paste one. | |
| # No `paths:` filter, deliberately: either input can move it, and both live | |
| # in one package, so a filter would only ever blind it. Runs its own | |
| # --self-test first. | |
| - name: Auth mount-vs-ledger guard | |
| run: pnpm check:auth-mount-ledger | |
| # Optional-`error` sink contract (#9754, wired by #10608). A sink TYPE that | |
| # declares `error` as OPTIONAL must also declare `warn` as NON-optional, so | |
| # every value of that type has somewhere to put a durability report — | |
| # silence stops being representable at AUTHORING time instead of being | |
| # caught one gate-run later. `AuthEventAuditLogger` declared `error?` and | |
| # `debug?` and no `warn` at all: the call site could not have been written | |
| # correctly against the contract it was given. Producer-side half of | |
| # AGENTS.md → Prime Directive #12; the call-site rule | |
| # (`check:durability-log-level`) is separate and unchanged. | |
| # | |
| # WHY THIS STEP EXISTS AT ALL: the gate landed with PR #10555 carrying its | |
| # root alias, and NO workflow ever invoked it — so from the hour it merged | |
| # it enforced nothing, while reading in `package.json` exactly like the | |
| # gates that do. That is #10574's defect one file over, and it is the only | |
| # thing this change repairs; the gate itself is untouched. | |
| # Runs its own --self-test FIRST (both directions, every narrowing pinned | |
| # as a count), then the scan. Deliberately carries NO counts: the run | |
| # prints its own census — self-test cases, sink-type population, and the | |
| # baselined shrink-only total — so a copy here could only ever be a stale | |
| # one, and was. AST over packages/**, no spawns; ~3.5 s. | |
| # 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 | |
| run: pnpm check:optional-error-sink | |
| # Namespace-wildcard fall-through guard (#4116). A handler mounted on | |
| # `<prefix>/*` claims the whole namespace, and Hono's first-registered | |
| # handler that answers wins — so a TERMINAL wildcard makes every other | |
| # route under that prefix reachable only by registration luck. That shape | |
| # has now cost four fixes (#2567, #4018, #4088/#4092, cloud#923) and every | |
| # one was found by hand, never by CI. Per-plugin tests cannot cover the | |
| # next one; what was missing is the ENUMERATION. Three states — yields | |
| # (verified from the AST, so it cannot rot) / exempt-with-reason / ratchet | |
| # — and an undeclared mount is an error, never a default. Runs its own | |
| # --self-test first. | |
| - name: Wildcard fall-through guard | |
| run: pnpm check:wildcard-fallthrough | |
| # Raw `:type` route-param comparison guard (#6241). The `/meta/:type` | |
| # routes serve BOTH spellings and Prime Directive #3 makes the PLURAL one | |
| # canonical, so a gate comparing the raw param is a gate the canonical | |
| # spelling skips. That is one authorization bypass fixed three times in | |
| # one file: #3984 (every per-type gate), #5881 (the dashboard exclusion), | |
| # #6241 (the doc/book cache exclusion, still literal eight days after | |
| # #3984's structural fix). Each was found by hand; per-defect tests pin | |
| # the gates that exist today and nothing refused the NEXT raw comparison. | |
| # AST-based on purpose — the file documents the bad pattern in prose, and | |
| # a textual scan would flag its own post-mortems. Zero exemptions today. | |
| # Runs its own --self-test first. | |
| - name: Normalized metadata-type guard | |
| run: pnpm check:meta-type-normalized | |
| # Filter-slot wire-alias parity guard (#8002). The ONE filter slot is | |
| # spelled four ways, declared in TWO packages: `where`/`filter` come from | |
| # the spec table, while the wire-only `filters`/`$filter` are declared in | |
| # `metadata-protocol` (no schema declares them) and named LITERALLY in | |
| # `packages/rest`, which gates the slot's arity at the querystring ingress | |
| # (#7390). A runtime import cannot join them — the normalizer's table is a | |
| # module-private const, and metadata-protocol is only a devDependency of | |
| # rest — so a fifth wire-only spelling would fold correctly and be | |
| # silently ungated, putting repetition on it back on the misdiagnosis | |
| # #7390 removed. The REST side alone is pinned by that card's §5 test; | |
| # this covers the half it cannot reach. AST-based, and an unreadable | |
| # 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 | |
| run: pnpm check:filter-alias-parity | |
| # Init-service declaration guard (#4471, ADR-0116). The kernel's ordering | |
| # contract (dependencies / optionalDependencies / requiresServices / | |
| # providesServices) was complete but VOLUNTARY: a plugin that resolves | |
| # getService('X') during init() and declares nothing fails only under | |
| # unlucky composition orders, usually inside a best-effort try/catch that | |
| # downgrades the miss to a warn. That silence shipped #4085 and #4420 (the | |
| # latter losing every in-flight approval on restart). This scan walks each | |
| # plugin's init() call graph from the AST — the #4420 call sat in a private | |
| # helper, not init()'s own body — and errors on any init-reachable | |
| # getService of a workspace-provided service that no declaration covers. | |
| # Declared tolerance stays in the plugin (optionalDependencies), never in a | |
| # checker-side ledger. Runs its own --self-test first. | |
| - name: Init-service declaration guard | |
| run: pnpm check:init-service-contract | |
| # Settings bind-window guard (#11045). The same argument as the gate above, | |
| # one lifecycle phase later. SettingsServicePlugin registers the service in | |
| # init() but binds the DATA ENGINE from a kernel:ready hook it registers in | |
| # start(); in between, the service answers reads from the empty in-memory | |
| # fallback and the manifest defaults with source: 'default' while a real | |
| # sys_setting row sits unread — indistinguishable, at every level, from "no | |
| # row exists" (#10250). Both sides of that ordering constraint are invisible | |
| # to a walk rooted at init(), so check:init-service-contract is green either | |
| # way. This one walks each plugin's start()-registered kernel:ready handlers | |
| # transitively — through local const bindings, which is where the live | |
| # plugin-auth case (depth 3) sits — and requires the settings plugin in | |
| # dependencies / optionalDependencies. Reads in init()/start() bodies get a | |
| # different verdict on purpose: no ordering edge can move those out of the | |
| # window, so the message says move the read, not declare an edge. Runs its | |
| # 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 | |
| run: pnpm check:settings-bind-window | |
| # Durability-degradation log-level guard (#4632). #4460 raised ONE | |
| # best-effort degradation from warn to error — the durable suspended-run | |
| # store writing to a table that never existed (#4420), which looked healthy | |
| # while every restart dropped all in-flight approvals. The rule that fix | |
| # implied now lives in AGENTS.md ("Degradation log levels"): a degradation | |
| # that costs only FUNCTIONALITY may warn; one that costs DURABILITY must | |
| # error, naming the consequence and the fix. This gate is what keeps that | |
| # from being one more declared-but-unenforced convention — it walks the AST | |
| # for catch blocks guarding a declared vocabulary of durability-critical | |
| # operations (schema DDL, suspended-run re-arm) and fails when one degrades | |
| # below `error` without rethrowing. Deliberately narrow: it cannot discover | |
| # a new durability seam, only stop known ones from regressing. Runs its own | |
| # --self-test first. | |
| - name: Durability-degradation log-level guard | |
| run: pnpm check:durability-log-level | |
| # Startup registry-verdict guard (#4777). One showcase cold start produced | |
| # three instances of one shape in three unrelated subsystems: ask a | |
| # registry "is X there?" while the boot is still filling it, treat the | |
| # "no" as final, and RECORD it — cached on the instance (#4772 | |
| # plugin-auth), asserted in a warn (#4771 service-automation), or written | |
| # to the database (#4769 objectql). The provider registers a moment later | |
| # and nothing undoes the record. This gate walks the AST for that | |
| # three-part shape: a pre-ready registry read (init()/constructor, or an | |
| # ADR-0018 open capability registry before its seal) whose absent verdict | |
| # is recorded. Reading is legal and must stay legal — a probe licensed by | |
| # an ADR-0116 declaration, or one deferred into a closure/kernel:ready | |
| # hook, is exactly the fix and is never flagged. Deliberately narrow: it | |
| # cannot see a probe behind an indirection in another package, and it | |
| # cannot see #4769 at all (that "registry" is a database table). Runs its | |
| # own --self-test first. | |
| - name: Startup registry-verdict guard | |
| run: pnpm check:startup-registry-verdict | |
| # objectui pin-changeset guard (#4731). A frontend-only refresh reaches the | |
| # platform release record ONLY through the changeset `bump-objectui.sh` | |
| # writes, and that list used to be guessed off objectui's commit subjects | |
| # (`feat|fix` + a silent `head -40`). On one real range the guess dropped | |
| # all 13 releasing non-feat/fix commits — every breaking `refactor(...)!` | |
| # among them — and pulled in 5 that release nothing. The list and the bump | |
| # level now come from what objectui DECLARED (`.changeset/*.md` added over | |
| # the range; empty frontmatter = release-nothing). This self-test builds | |
| # throwaway git repos carrying those exact shapes, runs the real scripts | |
| # over them, and asserts the emitted changeset — including that a cap which | |
| # fires announces itself and that an unwalkable range degrades loudly. | |
| # #4843 folded `scripts/objectui-range.mjs` — the release page's Console | |
| # 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 | |
| run: pnpm check:objectui-changeset | |
| # objectui pin write-ordering guard (#10797). Its sibling above is about | |
| # WHAT the bump writes; this one is about WHEN. `bump-objectui.sh` wrote | |
| # `.objectui-sha` before it read the commit subject out of the objectui | |
| # checkout, so a commit object that cannot be read killed the run under | |
| # `set -e` with the pin file ALREADY REWRITTEN — no changeset, no commit, | |
| # a bare `fatal: bad object` as the whole explanation, and a tree the | |
| # operator had to clean up by hand. Re-running did not self-correct: the | |
| # pin file now held the bad SHA. Reachable with no argument at all — | |
| # measured, git 2.43.0, `git rev-parse HEAD` exits 0 and prints the sha | |
| # for a commit whose object is missing, because it resolves the ref | |
| # without reading the object. | |
| # | |
| # The self-test asserts the invariant on BYTES, not on the message: both | |
| # the broken and the fixed script exit non-zero on this input (128 from | |
| # `set -e` vs 1 from the refusal), so "did it fail?" does not tell them | |
| # apart and a test asking only that would have passed against the defect. | |
| # It also drives two readable-commit cases all the way through, because | |
| # `exit 1` at the top of the script would satisfy every refusal case on | |
| # its own. Offline: throwaway git repos, no node, no network, ~1s. | |
| - name: objectui pin write-ordering guard | |
| run: pnpm check:objectui-bump | |
| # Changeset-family gate self-tests (#6509). The SELF-TEST halves only — | |
| # the real scans stay in pr-automation.yml's `changeset-check`, and the | |
| # split is the whole point of this step. | |
| # | |
| # Why they moved here. `check-empty-changeset.mjs` and | |
| # `check-adr-0087-registration.mjs` were called from exactly one place in | |
| # the repository: the `changeset-check` job, which a PR carrying | |
| # `skip-changeset` is exempted from WHOLESALE (job-level `if:` plus the two | |
| # per-step label reads). And "this PR edits a CI-internal script" is the | |
| # textbook `skip-changeset` case — such a PR releases nothing, so by the | |
| # workflow's own prescription it takes the label. The consequence is the | |
| # exact inversion of #4690: a PR that edits these two checkers is the PR | |
| # most likely to skip their own fixtures. | |
| # | |
| # Not hypothetical, and not this card's own reasoning either. PR #6876 | |
| # (#5620, merged) added FIVE consumer assertions to | |
| # `check-empty-changeset.mjs` that never executed once on its own CI: it | |
| # carried `skip-changeset`, so on run 31289894461 the step | |
| # `Reject an empty-frontmatter changeset added by this PR` — the only thing | |
| # that runs `--self-test` — reports `conclusion: skipped`. Every assertion | |
| # in that PR was verified locally and by nothing else. | |
| # | |
| # This step is deliberately UNCONDITIONAL. No `if:`, no label read, no | |
| # paths filter: an exemption is precisely what the two self-tests must not | |
| # have, or the gap simply moves. That property is not left to prose — | |
| # `check-empty-changeset.mjs`'s own consumer block asserts this step's | |
| # shape (present, unguarded, self-test halves only), so removing or | |
| # conditioning it reds the very check it was removing. | |
| # | |
| # Why the SELF-TEST halves only, and not `pnpm check:empty-changeset`: | |
| # that script chains the real scan, whose verdict is a function of the | |
| # PR's DIFF and therefore needs `$MERGE_BASE`. This job has no branch | |
| # point, so a real scan here would fall back to reading stock — which is | |
| # #6129, "judge the author for what main gained while their PR was open", | |
| # in the direction of a false RED. The self-test halves have no such | |
| # dependency: measured, both pass with REPO_ROOT pointing at a directory | |
| # that is not a git repository at all. They build their own throwaway | |
| # repos in $TMPDIR and read two files (this workflow and | |
| # pr-automation.yml). ~0.8s + ~5.0s. | |
| - name: Changeset-family gate self-tests | |
| run: pnpm check:changeset-gate-self-tests | |
| # Release-notes drift guard: the platform is one version-locked train, so | |
| # every released @objectstack/spec major must have a curated, navigable | |
| # release page at content/docs/releases/v<major>.mdx. Catches the gap that | |
| # let v10–v14 ship with no page while spec was already at 14.x. | |
| - name: Release-notes drift guard | |
| run: pnpm check:release-notes | |
| # Release-page STATUS guard (#8892). The guard above proves a released | |
| # major has a page; it never reads what the page SAYS. Both of the last | |
| # two GA cuts shipped a page still describing itself as a pre-release — | |
| # v17 for a day ("the train is preparing to ship"), v16 for three weeks | |
| # ("currently published as 16.0.0-rc.0") — with CI fully green each time, | |
| # found only because a human read the page. This asserts that for every | |
| # GA'd major from v16 on, the status blockquote and the index entry both | |
| # 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 | |
| run: pnpm check:release-page-status | |
| # Release SECTION-COVERAGE guard (#10268). The guard above is keyed on the | |
| # MAJOR: `releasedAssertionRe(17)` is satisfied by ANY 17.x.y released-claim, | |
| # so a page saying "17.0.0 is released" passes forever however many 17.x | |
| # minors ship after it. Reconstructed from history and measured on this | |
| # change's base, that gate exits 0 on the exact #10232 state — v17 page with | |
| # no 17.1 heading and an index still reading "(current series: 17.0.0)" while | |
| # 17.1.0 had been on `latest` for hours. Third occurrence of the class | |
| # (#8886/#8882 -> the gate above, #8917 -> no gate, #10232). | |
| # | |
| # ADVISORY BY DEFAULT, AND THAT IS MEASURED, NOT TIMID. The CHANGELOG heading | |
| # lands in the version commit that publishes, so a hard failure starts the | |
| # instant a minor ships and lasts until someone writes curated prose. Timed | |
| # from git: 17.1.0 ran 5h44m (36 PRs landed), 16.1.0 ran 25 days (2712 PRs). | |
| # Hard-failing would have red 2748 PRs for a debt none of them created. So | |
| # findings are a `::warning::` + step summary with the job GREEN — the | |
| # loud-but-non-blocking shape prerelease-pin-watch.yml uses — and the policy | |
| # follows half-state-patrol.yml's rule verbatim: findings never fail | |
| # anything, the job DOES fail when the sweep could not run. A broken | |
| # instrument is fatal here in every mode. `--strict` promotes findings to | |
| # exit 1 for a caller that owns the remedy; nothing on a PR passes it. | |
| # | |
| # No `pnpm check:*` alias: see the GATE INVOCATION IDIOM note at the top | |
| # of this file. | |
| - name: Release section-coverage guard | |
| run: | | |
| node scripts/check-release-section-coverage.mjs --self-test | |
| node scripts/check-release-section-coverage.mjs | |
| # Release-body limit guard (#4900). The GitHub Releases API rejects any | |
| # body over 125,000 characters, and @objectstack/spec's changelog section | |
| # for one v17 RC is ~343,000 — so the release step 422'd after npm had | |
| # already published, took `published` down with it, and silently lost the | |
| # runtime image. scripts/release-github-releases.mjs now builds those | |
| # bodies; this runs its --self-test, which feeds the REAL oversized | |
| # section out of packages/spec/CHANGELOG.md through the real code path and | |
| # asserts the result fits, still links the full entry, closes any code | |
| # fence the cut opened, and splits no surrogate pair. It also covers the | |
| # properties the failure taught us to want: every package in the fixed | |
| # group gets a release, a re-run updates instead of 422-ing on | |
| # `already_exists`, and one package's rejection no longer abandons the | |
| # rest — @objectstack/spec keeps its release, which is where ADR-0087 D4's | |
| # spec-changes.json is attached. | |
| - name: Release-body limit guard | |
| run: pnpm check:release-body | |
| # #3825 Node-version drift guard: a runtime pin is 18 separate string | |
| # literals across .github/workflows, so a split is invisible until someone | |
| # greps for it. One did open — every PR gate sat on Node 20 (EOL | |
| # 2026-04-30) while release.yml and publish-smoke.yml ran 22, so code was | |
| # verified on one runtime and shipped from another. Nobody chose that; one | |
| # workflow got bumped to clear one error and the rest stayed behind. It | |
| # surfaced only when a dependency needing >=22 aborted the vitest worker | |
| # at the process level, which vitest reported as a PASSING suite with 17 | |
| # cases silently skipped (#3812). .nvmrc is now the single source of | |
| # truth, and this holds every workflow to it. | |
| - name: Node-version drift guard | |
| run: pnpm check:node-version | |
| # pnpm-acquisition census. Sibling of the guard above, and here for the | |
| # failure the guard above cannot have: a census that reaches ZERO while | |
| # the thing it measures is still there. The `corepack enable` sites are | |
| # moving onto .github/actions/setup-pnpm, and when that finishes a grep | |
| # for `corepack enable` returns 0 — a true answer about Corepack that | |
| # reads as a complete answer about pnpm acquisition. It is not one: | |
| # showcase-smoke.yml acquires pnpm through `pnpm/action-setup`, which | |
| # contains no Corepack string and is invisible to that key. A metric that | |
| # hits zero while its subject survives is worse than no metric, because it | |
| # retires the attention. | |
| # | |
| # So this gate is keyed on the NEED, not the mechanism: the population is | |
| # every job that RUNS pnpm. A mechanism nobody has taught it cannot become | |
| # a silent zero — it surfaces as a job running pnpm with no recognised | |
| # acquisition, which fails loudly. It prints the full census on SUCCESS | |
| # (every mechanism keeps a row, including the ones at 0), so the answer to | |
| # "how does CI get pnpm" is one job log rather than a grep nobody thinks | |
| # to re-derive. It also pins the ordering that killed two jobs in this | |
| # repo with "Unable to locate executable file: pnpm": a setup-node asking | |
| # 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 | |
| run: pnpm check:pnpm-acquisition | |
| # Workflow status-function guard (#5343, from #4900 and #4928). GitHub | |
| # wraps any `if:` that names no status function in an IMPLICIT success(). | |
| # So a job-level condition written to read an upstream job's OUTPUT VALUE | |
| # silently also carries a status decision nobody wrote, and the two facts | |
| # "the upstream job DIED" and "the upstream job said don't run" arrive as | |
| # the same skip — which every checks list renders as green. #4900 found a | |
| # release-integrity guard that stopped guarding exactly when the job before | |
| # it failed; #4928 found seven ci.yml gates that turned themselves off | |
| # whenever something upstream broke. Both were caught by hand, months | |
| # apart, and the rule is statically decidable — so it is a gate now. | |
| # Job-level `if:` reading `needs.*.outputs.*` only: step-level conditions | |
| # and `needs.*.result` are deliberately out of scope (see the script | |
| # header). Real YAML parse, not a grep over `if:` lines — a grep cannot | |
| # distinguish a malformed workflow from a clean one and cannot see the | |
| # folded `if: >-` two of these workflows already use. Runs its own | |
| # --self-test first: the detector can break while every workflow is fine, | |
| # and a scan that quietly stops matching would report OK while reading | |
| # nothing (#4690's family). | |
| - name: Workflow status-function guard | |
| run: pnpm check:workflow-status-functions | |
| # Additive-label-write self-test (#10703). `pr-automation.yml` writes this | |
| # PR's labels with `scripts/pr-labels.mjs`, whose whole contract is that it | |
| # emits POST and targeted DELETE and NEVER a whole-set | |
| # `PUT /issues/{n}/labels` -- the verb that erased a seat-applied | |
| # `skip-changeset` one second after it was written on PR #10698, turning a | |
| # PR that publishes nothing into a false `changeset-check` red. The | |
| # self-test pins the pure write-plan builders (asserting no plan any input | |
| # can produce carries that verb), the size buckets (`<`, not `<=`, matching | |
| # the action it replaced) and the minimatch subset the path matcher | |
| # implements -- and it parses the REAL checked-in `.github/labeler.yml`, so | |
| # a pattern that drifts outside that subset fails here instead of silently | |
| # mislabelling PRs. | |
| # | |
| # It runs in THIS job and not only in `Check PR Size` because that context | |
| # is deliberately excluded from the required set (a `labeled` event | |
| # republishes it as `skipped`, which washes green -- see | |
| # check-required-contexts.mjs), so a red there blocks nothing. | |
| - name: Additive label-write self-test | |
| run: node scripts/pr-labels.mjs --self-test | |
| # The other half of that contract (#10778, split out of #10703). The step | |
| # above proves ONE script never emits a whole-set write; this one proves | |
| # nothing in the repo does. #10703 removed the two whole-set | |
| # `PUT /issues/{n}/labels` writes, but it could not make the verb | |
| # UNAVAILABLE -- a newly added third-party labeler, or a second workflow | |
| # calling the endpoint directly, reopens the same defect, and until this | |
| # gate the only guard was a prose paragraph in pr-automation.yml's header. | |
| # | |
| # A whole-set PUT destroys any label that lands between its read and its | |
| # write. Its second-order cost is the expensive one: while the verb is | |
| # reachable, "the label is absent" stops meaning anything, because absence | |
| # has two causes (cleared deliberately, or erased) and read-back is the | |
| # only detection there is. | |
| # | |
| # Three assertions, all from the card: no PUT against the labels endpoint | |
| # in ANY spelling (`curl -X PUT`, `gh api --method PUT`, `issues.setLabels`, | |
| # `octokit.request('PUT /repos/...')`, a `method: 'PUT'` fetch); no `uses:` | |
| # of an action measured to write the whole set; and an allowlist entry | |
| # REQUIRES a stated reason -- the gate refuses rather than passes on an | |
| # entry without one. | |
| # | |
| # Invoked as `node` rather than through a `pnpm check:*` alias, same as the | |
| # other `node scripts/...` steps in this lane: see the GATE INVOCATION | |
| # IDIOM note at the top of this file. | |
| # | |
| # Its --self-test runs first and is not ceremony. The gate is GREEN OVER AN | |
| # EMPTY EXECUTABLE POPULATION -- every mention of the verb on this tree is | |
| # in the comments that document the ban -- so a passing run over real data | |
| # cannot by itself distinguish a working matcher from one that matches | |
| # nothing. Two things answer that: the verdict line prints the judged | |
| # population (files, raw mentions, comment-cleared, executable, `uses:` | |
| # pins) so a zero is a measurement rather than a silence, and the gate | |
| # REFUSES if the declared live probes in PROSE_PROBES stop matching. The | |
| # self-test drives the real sweep red over fixture trees on disk. | |
| # Static text only, no build, so it belongs in this pre-build group. | |
| - name: No whole-set label PUT anywhere in the repo (#10778) + its self-test | |
| run: | | |
| node scripts/check-whole-set-label-write.mjs --self-test | |
| node scripts/check-whole-set-label-write.mjs | |
| # Cross-repo closer outcome contract (#9595, and #9575 before it). | |
| # `cross-repo-issue-closer.yml` carries ~150 lines of inline | |
| # github-script, and it is code nobody has ever seen run: over the 1176 | |
| # most recently merged PRs, ZERO bodies carry a qualified cross-repo | |
| # closing keyword, so the branch that closes foreign issues has had no | |
| # target in that whole window. Its defects are therefore found by reading | |
| # — one card per silent exit — and each fix lands as more unexercised | |
| # code. This step is the exercise: the shipped script is extracted from | |
| # the YAML with a real parser (never retyped) and run under doubles the | |
| # way actions/github-script runs it, as one AsyncFunction body. Its | |
| # scenarios pin the target parse and the outcome of EVERY exit — which of | |
| # setFailed / warning / job summary fires, and which API calls were made. | |
| # How many of them there are is deliberately NOT written here (#9917): | |
| # the check names the total on its own OK line, and `--list` prints the | |
| # whole table. This comment used to say "ten" against a real eighteen. | |
| # | |
| # Assertion 0 is the compile, and it is not theoretical: this job failed | |
| # twice on 2026-08-02 with `SyntaxError: Identifier 'octokit' has already | |
| # been declared`, i.e. a script that never ran at all, on a post-merge | |
| # workflow whose red nothing else in CI can see. | |
| # | |
| # Its --self-test runs first and is the half that stops the battery | |
| # rotting into decoration: it mutates the shipped script along every | |
| # CLASS the contract is made of — a verdict downgraded (red becomes a | |
| # warning, or an info line nothing annotates), the loop's bookkeeping | |
| # deleted (which keys failed, which half was lost, which target was | |
| # refused), isolation abandoned mid-loop, the target parse narrowed (the | |
| # keyword set, the optional colon, the same-repo skip), a guard removed | |
| # (already-closed, triage, pull-request), idempotency eroded (the | |
| # backlink marker dropped, or a blind post where a skip belongs) — and | |
| # requires the battery to go RED for each, naming the scenario that | |
| # catches it. A mutation whose anchor no longer exists is a failure too, | |
| # so a rewrite of the workflow cannot leave the mutations silently | |
| # matching nothing. | |
| # | |
| # Classes rather than a count, and rather than the enumeration that used | |
| # to sit here, because BOTH hand-maintained numbers in this comment had | |
| # gone stale unnoticed — measured 2026-08-20 from the check's own output, | |
| # eighteen scenarios and fifteen mutations against a comment claiming ten | |
| # and seven (#9917). A stale enumeration reads exactly as authoritative | |
| # as a fresh one, and this comment is what the next reader checks the | |
| # harness against. Prose classes survive an M16; a count does not. | |
| # | |
| # Invoked as `node` rather than through a `pnpm check:*` alias: see the | |
| # GATE INVOCATION IDIOM note at the top of this file. Same shape as the | |
| # release-rehearsal step above. No network, no build; ~0.2 s. | |
| - name: Cross-repo closer outcome contract | |
| run: | | |
| node scripts/check-cross-repo-closer-outcome.mjs --self-test | |
| node scripts/check-cross-repo-closer-outcome.mjs | |
| # Closing-keyword parser parity (#9755). THREE files parse GitHub's | |
| # closing-keyword grammar out of user-authored markdown — the cross-repo | |
| # closer, the duplicate-fix guard, and H7's `closingKeywordRe()` behind | |
| # partof-closing-keyword-guard.yml — and they had drifted: the guard | |
| # accepted `Fixes: <ref>` and the closer silently did not, so a merged PR | |
| # written with the colon took the closer's quiet green exit and lost its | |
| # foreign issue. | |
| # | |
| # A shared module is the right shape and is unavailable: neither workflow | |
| # checks the repo out (the closer deliberately, on `pull_request_target`; | |
| # the guard because its whole job is one github-script step), so | |
| # `require()` resolves to nothing in either. This gate is what stands in | |
| # for the import — it extracts all three parsers from the shipped bytes | |
| # and asserts they agree BEHAVIOURALLY on the keyword set and the | |
| # separator, while still refusing what each is scoped to refuse. It also | |
| # sweeps all tracked files for a FOURTH parser, because "fix these two" | |
| # is exactly the change that would miss one. | |
| # | |
| # Invoked as `node` rather than a `pnpm check:*` alias for the same reason | |
| # as the step above: see the GATE INVOCATION IDIOM note at the top of this | |
| # file. No network, no build; ~1 s, most of it the tracked-file sweep. | |
| - name: Closing-keyword parser parity | |
| run: | | |
| node scripts/check-closing-keyword-parity.mjs --self-test | |
| node scripts/check-closing-keyword-parity.mjs | |
| # Merge-queue triage outcome contract (#10128, over the #4859 bot). | |
| # merge-queue-triage.yml carries ~450 lines of inline github-script that | |
| # only ever runs on a RED merge_group build — a condition nobody can | |
| # produce on demand, so its two 2026-08-20 limbs would otherwise be | |
| # unexercised code. This step is the exercise, on the same method as the | |
| # cross-repo closer step above: the shipped script is extracted from the | |
| # YAML with a real parser (never retyped) and run under doubles the way | |
| # actions/github-script runs it, as one AsyncFunction body. No network. | |
| # | |
| # What it holds: (1) the excerpt names the failure REASON beside the FAIL | |
| # line, driven against REAL captured vitest logs of the incident's own | |
| # test file — one timeout, one assertion, byte-identical FAIL lines and | |
| # opposite diagnoses, which is the confusion that produced a confidently | |
| # wrong card (#10112) and cost a whole dispatch; and (2) the cross-PR | |
| # aggregation files exactly ONE anchor issue at >= 2 DISTINCT PRs, | |
| # REFRESHES it on the next ejection instead of filing a second, and files | |
| # none for two different keys or for one PR ejecting twice. The refresh | |
| # leg is driven as a PAIR — run 2's world is built from run 1's output — | |
| # because idempotency is a property of the pair and a hand-written marker | |
| # would prove each half while leaving the two runs free to disagree. | |
| # | |
| # Its --self-test runs first and is the half that stops the battery | |
| # rotting into decoration: it mutates the shipped script fourteen ways — | |
| # drop the reason lookahead, test the FAIL pattern before the reason | |
| # pattern (which silently loses every assertion), count runs instead of | |
| # distinct PRs, always create instead of refreshing, drop the sighting | |
| # markers, downgrade each anti-no-op announcement to an info line, and | |
| # more — and requires the battery to go RED for each, naming the scenario | |
| # that catches it. A mutation whose anchor no longer exists is a failure | |
| # too, so a rewrite of the workflow cannot leave the mutations silently | |
| # matching nothing. | |
| # | |
| # Invoked as `node` rather than through a `pnpm check:*` alias, same | |
| # reason as the step above: see the GATE INVOCATION IDIOM note at the top | |
| # of this file. | |
| - name: Merge-queue triage outcome contract | |
| run: | | |
| node scripts/check-merge-queue-triage-outcome.mjs --self-test | |
| node scripts/check-merge-queue-triage-outcome.mjs | |
| # Shard positive-attestation gate (#6082). ci.yml's two aggregate gates | |
| # used to decide from one `needs.<matrix>.result` word, which cannot carry | |
| # three shards' verdicts: run 31120902911 read the undocumented | |
| # `abandoned` produced by runner starvation and painted a false red (PR | |
| # #6010 evicted 31s later), and run 31114735713 had a shard at | |
| # conclusion=failure while that same read said `abandoned` — a real | |
| # failure swallowed. Both gates now COUNT one "I ran and passed" artifact | |
| # per declared shard. This guard holds that arrangement to its own | |
| # declaration: the roster each gate counts must equal the matrix each job | |
| # runs (GitHub cannot share one literal between `strategy.matrix` and a | |
| # downstream step, so the two are reconciled here rather than remembered), | |
| # the credential steps must be the LAST steps of their job with no `if:` | |
| # (anything after them can fail the job while the credential already | |
| # counts as a pass), and every attesting job must be counted by exactly | |
| # one gate. Runs its own --self-test first — which is also where the | |
| # dominance experiment lives, since a dev cannot fabricate a real | |
| # runner-starved CI run. | |
| - name: Shard attestation gate | |
| run: pnpm check:shard-attestation | |
| # Aggregator roster gate (#10490). Three required contexts are aggregate | |
| # jobs standing in for a set of real jobs — `Test Core` and `Dogfood | |
| # Regression Gate` in ci.yml, `TypeScript Type Check` in this file — and | |
| # each decides its verdict from what it lists in `needs:`. Nothing held | |
| # those rosters to anything. Add a lane to a workflow, forget the | |
| # `needs:` line, and the lane's check-run is ADVISORY (only the aggregate | |
| # NAME is in the ruleset's required set), so the aggregate goes green | |
| # without ever looking at it: a job that appears in the checks list, | |
| # appears required, and blocks nothing. Neither existing gate covers it — | |
| # `check:required-contexts` pins the aggregate's NAME and job id and says | |
| # nothing about `needs:`, and `check:shard-attestation` covers only the | |
| # `--leg`-to-`needs:` direction for ci.yml's two. | |
| # Each aggregate now DECLARES its membership in job-level | |
| # `OS_AGGREGATOR_MEMBERS` / `OS_AGGREGATOR_NON_MEMBERS` (triage's ruling | |
| # on the card: an explicit per-aggregator declaration, local to the | |
| # workflow file, NOT a repo-wide job-id naming convention), and this gate | |
| # asserts declaration == `needs:` in BOTH directions. Every unreadable | |
| # state is a refusal rather than a quiet pass — no aggregator found, a | |
| # workflow that will not parse, or a roster that resolves to zero members | |
| # all exit 1 naming what could not be read (#4690). | |
| # Invoked as `node` rather than through a `pnpm check:*` alias: see the | |
| # GATE INVOCATION IDIOM note at the top of this file. Reads two YAML | |
| # files; sub-second. | |
| - name: Aggregator roster gate | |
| run: | | |
| node scripts/check-aggregator-roster.mjs --self-test | |
| node scripts/check-aggregator-roster.mjs | |
| # Required-context name pin (#6865). A GitHub required status check is | |
| # matched BY CHECK-RUN NAME, and a job's check-run name is its `name:` | |
| # value — so eight job `name:` literals across this file and ci.yml are | |
| # branch-protection contract, and until now nothing asserted them. | |
| # Renaming one is a one-line edit whose consequence is silent and | |
| # repo-wide: the old context never reports again (permanent pending, which | |
| # wedges the PR and the queue), or it gets dropped from the settings to | |
| # unwedge things and every gate that job carries degrades to advisory. | |
| # That second shape is #5617 — PR #5584 merged with THIS job red for 19 | |
| # minutes, and four more merges repeated it the same night. ci.yml states | |
| # the contract in prose in eight places and check:shard-attestation | |
| # asserts the two aggregate gates' job IDS; neither pins a name literal, | |
| # and this file had neither. The pin also holds each enrolled job to the | |
| # shapes that let a bare name report at all (no matrix, no | |
| # continue-on-error) and each enrolled workflow to the triggers a required | |
| # context needs (`merge_group`, and a `pull_request` with no `paths:`) — | |
| # the machine-readable form of #5617's audit exclusions. It deliberately | |
| # does NOT judge job-level `if:`: four of the enrolled ci.yml jobs carry a | |
| # filter-driven `if:` by design (#4928), and a skipped job still publishes | |
| # a context. ⚠️ It does not read the required SET itself — but not because | |
| # it cannot: the rulesets API answers 200 to an ordinary seat (it is the | |
| # CLASSIC branches/main/protection endpoint that answers 403, and this repo | |
| # does not use classic branch protection — #9642). The live diff is a | |
| # separate report-only mode, `--verify-required-set`, deliberately kept OFF | |
| # this required path: the settings half of a rename is maintainer-only and | |
| # lands after the merge, so a blocking version would be red on the very PR | |
| # carrying the repo half. This step stays network-free. | |
| # Reads two YAML files; sub-second. | |
| - name: Required-context name pin | |
| run: pnpm check:required-contexts | |
| # Cross-package test inputs (#7802). A test that resolves the repo root | |
| # and walks it has an input set far wider than its own package, and BOTH | |
| # of CI's scoping layers judge it by where it LIVES: `turbo ls --affected` | |
| # reaches packages through the dependency GRAPH, and turbo's task cache | |
| # hashes `$TURBO_DEFAULT$`, which is package-local. So spec's | |
| # `*.object.ts` conformance scan went unrun on a platform-objects-only | |
| # diff, AND replayed a cached green in the merge-queue build that runs the | |
| # full package list — a violation reached `main` with every gate green and | |
| # was then charged to the next unrelated PR that happened to touch `spec`. | |
| # Twenty such tests, in eight packages, exist today. | |
| # This gate finds the escaping tests ITSELF, statically — nothing has to | |
| # be registered to be DETECTED — and fails on any package that has one and | |
| # no declared input radius, on a declaration gone stale, on a declared | |
| # glob turbo.json does not hash, and on a radius too narrow to cover the | |
| # paths its own tests name. The point is what happens by default: a new | |
| # cross-package scan nobody remembers to register produces a RED GATE | |
| # naming it and the globs to write, not a silent skip. | |
| # Reads ~2000 test sources; sub-second. | |
| - name: Cross-package test inputs | |
| run: pnpm check:cross-package-test-inputs | |
| # Live-server database isolation (#10382). CI provisions ONE Postgres and | |
| # ONE MySQL for the whole temporal-conformance job and points every live | |
| # leg at them, and every live suite in the repo issues a `drop` when it | |
| # finishes — so two suites naming the same database do not contend, they | |
| # destroy each other's fixture. When the shared name is the one the | |
| # connection URL itself carries, the next suite cannot even complete its | |
| # handshake: measured on a live MariaDB 10.11, `Error: Unknown database | |
| # 'conformance'`, deterministically, without any concurrency needed. | |
| # | |
| # #9350 fixed this and enforced it — inside `packages/drivers/driver-sql` | |
| # only. That scan was correctly green while two live suites in | |
| # `packages/metadata-protocol` named their databases with hand-typed | |
| # constants, distinct from each other only because two authors typed two | |
| # different strings. A per-package scan cannot see the package that has | |
| # not been written yet, and the next live suite will be in a third one. | |
| # | |
| # This gate is the repo-wide half and nothing has to opt in: it finds the | |
| # live files itself (they are the ones reading OS_TEST_*_URL) and fails | |
| # any whose database name reaches its DDL from a literal. It does NOT | |
| # check distinctness — that needs the derivation actually run, and lives | |
| # in the two packages' own isolation suites. Reads ~4 files; sub-second. | |
| - name: Live-server database isolation | |
| run: pnpm check:live-db-isolation | |
| # LAYER C of the gate above (#10379). That gate verifies two of the three | |
| # layers a cross-package declaration has: it finds the escaping tests | |
| # itself, and `--verify` makes turbo.json hash every declared glob so the | |
| # task hash moves. Neither can see the third — the SCHEDULER has to start | |
| # the ci.yml `test` job that Layer A's `--union-into` step lives inside, | |
| # and it decides that from `crosspkg:`, a hand-kept list in ci.yml | |
| # mirroring the roots those declarations name. Nothing held the two in | |
| # step: a declaration landing in a root no entry covers (`docker/`, | |
| # `paseo.json`, a second `skills/*` bundle, another `docs/…` file) left | |
| # the gate above GREEN and the turbo hash moving while the test still did | |
| # not run at PR time, with the merge queue as the first signal — #7802's | |
| # shape one layer up, and the failure #10015 was filed for after #9829 | |
| # fixed one root. | |
| # The rule is PURE STRING — a declared glob is covered iff a scheduling | |
| # list contains it verbatim or contains `<prefix>/**` for a directory | |
| # prefix of its leading literal segments. Deliberately not a | |
| # file-instantiating matcher: `core:` carries the extglob | |
| # `apps/!(docs)/**`, so matching would need a picomatch-compatible | |
| # engine — a THIRD recognizer of the same declarations, which is the | |
| # defect this gate is about. Sound (it never reports covered for a path | |
| # the scheduler would miss) and knowingly incomplete in the cheap | |
| # direction (an entry with a wildcard of its own covers nothing, costing | |
| # one line of YAML). It also reads the `test` job's `if:` and refuses if | |
| # either filter has left the OR, and reports a `crosspkg` entry that | |
| # covers no declaration any more. | |
| # Invoked as `node` rather than through a `pnpm check:*` alias for the | |
| # same reason as the steps below: see the GATE INVOCATION IDIOM note at | |
| # the top of this file. Two YAML parses and a string comparison; | |
| # milliseconds. | |
| - name: CI filter parity (cross-package Layer C) | |
| run: | | |
| node scripts/check-ci-filter-parity.mjs --self-test | |
| node scripts/check-ci-filter-parity.mjs | |
| # The READING half of the gate above (#10046). `--union-into` appends the | |
| # cross-package scans to the `turbo ls` document ci.yml is about to shard, | |
| # and `scripts/partition-test-shards.mjs` is the only thing that reads it. | |
| # That script asserts the payload shape loudly on purpose — an | |
| # experimental-command upgrade should become a red step naming the cause | |
| # rather than a silently empty shard — but it had carried a `--self-test` | |
| # that NOTHING ran since it was written, so every assertion in it, the | |
| # partitioner's determinism and coverage pins included, evaluated never. | |
| # A pin nobody runs is not a weaker pin, it is no pin: `--union-into` wrote | |
| # a `count: 0` document alongside two items for as long as nobody looked, | |
| # and the reader-side refusal that now catches that needs a live pin of its | |
| # own or it rots the same way. | |
| # Invoked as `node` rather than through a `pnpm check:*` alias for the same | |
| # 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 | |
| run: node scripts/partition-test-shards.mjs --self-test | |
| # Hand-written `.d.mts` mirrors (#10549). `scripts/js-comment-mask.mjs` | |
| # and `scripts/check-regen-pending.mjs` are untyped `.mjs` that each ship | |
| # a hand-written declaration beside them, and both files say "keep this in | |
| # step with the module by hand". Nothing checked that they were. A | |
| # `.d.mts` has no runtime existence, so nothing executes it and nothing | |
| # notices: TypeScript consumers see ONLY the declaration, so a drift makes | |
| # every consumer type-check GREEN against a signature the module does not | |
| # implement, first symptom a runtime failure downstream. That this file | |
| # silently decides typecheck outcomes is measured rather than theoretical | |
| # — PR #10513 went red with `TS2578` on two lanes purely because the | |
| # mirror existed on the merged tree and not on the branch's, and that | |
| # episode cost PR #10450 two merge-queue evictions. This gate asserts | |
| # name, kind and required arity per declared export, and it DISCOVERS its | |
| # corpus (every `scripts/**/*.d.mts`), so a third mirror added tomorrow is | |
| # covered by existing rather than by anyone remembering to enrol it. | |
| # Invoked as `node` rather than through a `pnpm check:*` alias for the | |
| # same reason as the two steps above: see the GATE INVOCATION IDIOM note | |
| # at the top of this file. Imports two small modules; milliseconds. | |
| - name: Hand-written declaration mirrors | |
| run: | | |
| node scripts/check-declaration-mirrors.mjs --self-test | |
| node scripts/check-declaration-mirrors.mjs | |
| # Published list mirrors (#10855). The cross-package gate above is a SOURCE | |
| # SCAN: a path spelling it does not know yields no flag, so the escaping read | |
| # goes undeclared SILENTLY. That is why its recognised set is published in | |
| # AGENTS.md instead of living only in the implementation — and nothing held the | |
| # two copies in step. It drifted three times (#10163, #10854, #10855); measured | |
| # on 1a47a5368 the published block was short by 13 of the 24 lines, the two | |
| # findUp ANCHOR seeds among them. ⭐ Twice the stale line was the stated REASON | |
| # FOR A PROHIBITION, so a rotting mirror does not merely misinform — it launders | |
| # an obsolete rule into a live one, and the fix has to derive a new true reason. | |
| # This asserts line-for-line EQUALITY (not containment: a comment-only drift is | |
| # invisible to containment, and the comments are where the prohibitions live), | |
| # locates the block by heading + fence rather than line number, and REFUSES | |
| # rather than passing empty when it cannot find it. ⛔ It can only ever go RED: | |
| # AGENTS.md is governed (human-merge-only), so it prints the block to paste and | |
| # 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 | |
| run: | | |
| node scripts/check-published-list-mirrors.mjs --self-test | |
| node scripts/check-published-list-mirrors.mjs | |
| # The inventory of `packages/**` tests coupled to `examples/**` (#8754). | |
| # Sibling of the cross-package gate above, on the axis that gate does not | |
| # own. ⚠️ The line that used to stand here — "that one detects tests whose | |
| # FILESYSTEM READS escape their package, this one detects tests that | |
| # IMPORT an example app live" — stopped being true in #10452, which taught | |
| # the cross-package gate to read escaping import SPECIFIERS as well as | |
| # path-shaped reads. Both now see the same couplings and do different | |
| # things with them: the cross-package gate turns one into a declared input | |
| # radius plus the turbo glob that hashes it, while this gate keeps the | |
| # `examples/**` INVENTORY and grades what CI can see of each entry. | |
| # `packages/cli` dynamically | |
| # imports `examples/app-showcase/src/ui/views/contact.view` and asserts | |
| # `toEqual` over a hardcoded `_sections` key list; `packages/lint` | |
| # statically imports the same app's `Contact`/`ContactViews`. Neither | |
| # package declares a dependency on `@objectstack/example-showcase` — the | |
| # coupling is a test-only relative import across a workspace boundary — so | |
| # `turbo ls --affected` cannot reach them from an examples-only diff and | |
| # the first signal is a red `Test Core` in the SHARED merge queue, which | |
| # stalls every lane (queue build 31825946401; it caught #8231's remainder | |
| # twice in one round, the second time through a package nobody had | |
| # thought to check). | |
| # ⛔ This gate grades DISCOVERY ONLY — "here are the coupled files, and | |
| # here is what CI can see of each". It asserts nothing about how any of | |
| # them should be written; adding a new live coupling is allowed, it only | |
| # has to be RECORDED so the next example-app edit can be checked against | |
| # the list. Classification is mechanical (declared workspace dep / | |
| # declared turbo input glob / neither), and only the invisible tier needs | |
| # a hand-written note, so the maintained half stays proportional to the | |
| # gap. Checked in both directions: an undeclared coupling and a stale | |
| # entry both fail, naming themselves. | |
| # Inventory on demand: `pnpm examples:live-imports`. | |
| # Static scan of test sources; sub-second. | |
| - name: examples/** live-import inventory | |
| run: pnpm check:examples-live-imports | |
| # Tests must judge SOURCE, not a sibling's build artifact (#7668/#7849). | |
| # Every publishable package resolves through `exports` to `dist/`, so a | |
| # package whose tests import a workspace dep with no vitest source alias | |
| # is reporting on build state. #7668 is the loud version of that (17 cases | |
| # erroring on a symbol its prebuilt core predated); the version this gate | |
| # is really aimed at is SILENT — a dist merely BEHIND the source runs | |
| # GREEN against old behaviour, and nothing in the output says so. | |
| # Demonstrated on this very repo: with `@objectstack/spec` unaliased, the | |
| # frozen downstream-consumer contract — the repo's backward-compatibility | |
| # gate for spec — reported 14/14 green against a spec source that rejected | |
| # its own fixture outright; aliased to source, the same tree went red. | |
| # Turbo's `test` dependsOn `^build` does not reach it, because the failing | |
| # paths are the ones turbo does not mediate (`pnpm test` in a package, | |
| # `vitest run <file>`, an editor runner, an agent in a stale tree). | |
| # The registry is measured, shrink-only and audited in BOTH directions, so | |
| # it cannot rot into a grandfather clause: an entry that is no longer | |
| # needed fails and names itself for deletion. Static scan of test sources | |
| # and vitest configs; no build needed, so it belongs in this job. | |
| - name: Test-source alias gate | |
| run: pnpm check:test-source-alias | |
| # The process-global ref'd-timer probe lives in ONE module (#10785). A | |
| # `'Timeout'` count derived from `getActiveResourcesInfo()` reports the | |
| # WHOLE process, so comparing two readings across an `await` scores a | |
| # subject against a number the test does not own — every co-tenant file's | |
| # timers and the runner's own non-unref'd 100ms throttle. The shape was | |
| # found FIVE times over four cards and repaired at the site each time; | |
| # twice the first signal was a shard-only intermittent red (once in CI, | |
| # `expected 2 to be 4`; once in the MERGE QUEUE, where it stalls every | |
| # lane at once) whose message names a timer count rather than the change | |
| # that caused it. Nothing prevented the sixth. | |
| # ⛔ The rule is deliberately NOT "is there an `await` between these two | |
| # readings" — that is an AST question, and a text scan that answers it | |
| # approximately fails SILENTLY on spellings it does not know, which is the | |
| # failure mode AGENTS.md already records for source-scanning gates. This | |
| # bans the RAW PROBE outside the one approved module instead: a | |
| # grep-level question, a one-entry allowlist, and a RED default that names | |
| # the file, the line and the import to write. Matched by IDENTIFIER rather | |
| # than by `process.`-receiver, so a destructure or a `node:process` named | |
| # import cannot slip past; comments are masked, so the docblocks that must | |
| # NAME the probe to explain the rule do not have to dodge the scanner. | |
| # A clean run also asserts the approved module is present and still reads | |
| # the probe — otherwise "nobody reads it" would be true of a tree where | |
| # the instrument was deleted. | |
| # Static scan of ~4.8k sources; sub-second. | |
| - name: Ref'd-timer probe containment | |
| run: pnpm check:refd-timer-probe | |
| # The TYPE axis of the same invariant (#8180). The gate above reads | |
| # `vitest.config.*` and nothing else, so the identical exposure on the | |
| # type axis was unguarded repo-wide — and its symptom is likewise a GREEN | |
| # `typecheck`. Measured on `packages/qa/downstream-contract` (#8021): | |
| # identical checkout, identical stale `dist`, a narrowing injected into | |
| # `packages/spec/src` in SOURCE only — without a `paths` block | |
| # `tsc --noEmit` exited 0 and clean; with one it reported the `TS2322` the | |
| # frozen fixture should always have produced. The comment beside the | |
| # type-check-debt step below documents the same mechanism as expected | |
| # state ("tsc resolves workspace imports through each dependency's built | |
| # `dist/*.d.ts`"), and turbo's `typecheck` dependsOn `^build` does not | |
| # reach it for the same reason it does not reach the runtime axis. | |
| # NOT a port of the gate above: tsc's `paths` algorithm is exact-key-wins | |
| # then longest-prefix (Vite's is first-match-wins), type-only imports | |
| # COUNT here, and `tsconfig.json` is JSONC that inherits through | |
| # `extends`. The trap is also different and worse — `@objectstack/spec*`, | |
| # star not after a separator, folds every namespace onto one target and | |
| # type-checks GREEN against the wrong module instead of crashing. | |
| # The registry is measured (51 of 76 packages on the day it landed), | |
| # shrink-only and audited in BOTH directions. It finds no new offender | |
| # today: it is a ratchet, so the remediation list is finite and cannot | |
| # grow behind anyone's back. Static scan of sources and tsconfigs; no | |
| # build needed (~1.2s), so it belongs in this job. | |
| - name: Type-source resolution gate | |
| run: pnpm check:type-source-resolution | |
| # #4248 packaging-hygiene guard. Without a `files` whitelist npm packs the | |
| # whole package directory, and 20 of the 49 publishable packages declared | |
| # none — so consumers installed TypeScript sources, unit tests and build | |
| # tooling, with dist/ landing on top of them rather than instead of them | |
| # (@objectstack/plugin-webhooks: 21 files, three of them unit tests). The | |
| # other 29 did declare it, so this was a hand-copied line with no gate — | |
| # the #3786 shape, where whoever forgets it gets no signal at all. Also | |
| # checks the whitelist is SUFFICIENT (covers every entry point, so | |
| # tightening one cannot ship a package that fails to resolve) and MINIMAL | |
| # (admits no test or build script), which keeps #4206's "`<pkg>/scripts/**` | |
| # is never runtime code" assumption continuously verified instead of | |
| # hand-checked. Runs its own --self-test first: the pattern semantics can | |
| # be wrong while every package is right. | |
| - name: Published-files whitelist guard | |
| run: pnpm check:published-files | |
| # Engine test-double contract gate (#4550, from #4434). A test double | |
| # LOOSER than the implementation it replaces turns a green suite into no | |
| # suite at all, silently, on exactly the paths a double was introduced | |
| # for. #4434 is the worked example: DELETE /sharing/rules/:idOrName | |
| # answered 500 for every rule and both address forms from the day it was | |
| # written, while `deleteRule drops rule + all its grants` asserted success | |
| # against a fake engine that accepted the one call shape ObjectQL.delete | |
| # refuses. This holds every fake ObjectQL engine's `delete` to the real | |
| # dispatch predicate — imported from @objectstack/objectql, not | |
| # hand-mirrored, so it cannot drift — with the pre-existing fakes in a | |
| # shrink-only, measured baseline. Static AST only, so it needs no build | |
| # and belongs in this job. Runs its own --self-test first: the detector | |
| # can be broken while every fake is fine, and a scan that quietly stops | |
| # matching would report OK while reading nothing (#4868's family). | |
| - name: Engine test-double contract gate | |
| run: pnpm check:engine-double-contract | |
| # WHERE-matcher conformance gate (#8494, from #7620). The read-side | |
| # sibling of the gate above, and the half its header lists as not | |
| # covered. #7620 corrected sixteen in-memory `matches(row, where)` | |
| # doubles that short-circuited on `$or` and discarded every sibling | |
| # equality key — turning a conjunction into a different query while the | |
| # suites stayed green — and #8494 is the observation one level up: | |
| # nothing held them corrected, so reinstating the early return failed | |
| # nothing. This lifts each discovered matcher out of its file and asks | |
| # it four combinator questions; it must answer correctly or throw, never | |
| # answer silently wrong. Behavioural rather than syntactic on purpose — | |
| # the second failure shape is an ABSENCE (no combinator branch at all), | |
| # which no pattern-match over source can see. Pre-existing combinator- | |
| # blind doubles are in a shrink-only measured baseline. Runs its own | |
| # --self-test first: the detector can be broken while every double is | |
| # fine, and a scan that quietly stops matching would report OK while | |
| # reading nothing (#4868's family). | |
| - name: WHERE-matcher conformance gate | |
| run: pnpm check:where-matcher | |
| # ObjectQL double `limit` gate (#11525, from #10978). The third member of | |
| # the family above, and the one the other two leave uncovered: a | |
| # `find(object, opts)` double that matches `where` correctly and then hands | |
| # back EVERY matched row. It cannot tell a read bounded at 200 from the same | |
| # read bounded at 1000, or from an unbounded one, so any limit change on | |
| # that read is green by construction and the production symptom is a | |
| # silently truncated result set rather than an error -- #10978's worked | |
| # example truncates an RLS input. PR #11521 converted nine doubles on the | |
| # authorization path and measured the population; #11525 is the observation | |
| # one level up: nothing held those nine right. This lifts each discovered | |
| # double out of its file and asks it a bounded question, encoding all three | |
| # shape rules the nine settled -- presence not truthiness, bound after the | |
| # filter, bound before any row-touching stage. Behavioural rather than | |
| # syntactic on purpose: the bound is applied through inline slices, per-file | |
| # helpers and shared helpers alike, and no pattern-match over source spans | |
| # them. Pre-existing doubles are in a shrink-only measured baseline. Runs | |
| # its own --self-test first: the detector can be broken while every double | |
| # is fine, and a scan that quietly stops matching would report OK while | |
| # reading nothing (#4868's family). | |
| - name: ObjectQL double limit gate | |
| run: pnpm check:objectql-double-limit | |
| # Paired kernel-hook pin gate (#5282, from #5170 / #5257 / #5274). The two | |
| # kernels — ObjectKernel (production) and LiteKernel (vitest / serverless / | |
| # edge) — run the same plugin code and the same hook vocabulary, but do NOT | |
| # share a class: ObjectKernel does not extend ObjectKernelBase, so each | |
| # keeps its own hooks map. #5282 shared the two dispatch LOOPS | |
| # (packages/core/src/hook-dispatch.ts); this gate covers what sharing them | |
| # cannot: three consecutive bugs were all "one hook name means opposite | |
| # things on the two kernels" (kernel:ready swallowed on one side and fatal | |
| # on the other; kernel:listening swallowing a failed server.listen() behind | |
| # a cheerful "Bootstrap complete"; kernel:shutdown skipping every destroy() | |
| # in the other direction), and each was caught by a human noticing the | |
| # asymmetry. What held the kernels together afterwards was a pair of | |
| # hand-written tests — a convention, not a mechanism: a fifth lifecycle | |
| # hook gets no pairing automatically and nothing goes red when it is | |
| # missing. So every kernel:* hook DISPATCHED in packages/core/src must be | |
| # named in a test title in BOTH kernel.test.ts and lite-kernel.test.ts, and | |
| # a missing pair fails naming the hook and the side that lacks it. | |
| # Subscriptions (ctx.hook) are deliberately not dispatches. Static AST over | |
| # five files, no build needed, so it belongs in this job. Runs its own | |
| # --self-test first: the detector can be broken while every hook is fine, | |
| # and a scan that stops matching would report OK while reading nothing | |
| # (#4868's family). Measured against main's corpus before being pinned | |
| # here: 4 dispatched hooks, 0 problems. | |
| - name: Paired kernel-hook pin gate | |
| run: pnpm check:kernel-hook-pairs | |
| # Plugin teardown-shape gate (#10619, from #9371 / #10371). `Plugin` | |
| # declares exactly one teardown hook — `destroy?()` — and both kernels | |
| # call only that one. A plugin whose teardown is spelled `stop()` is | |
| # therefore never torn down: `await kernel.shutdown()` RESOLVES with its | |
| # timers still armed. #9371 measured 48 further delivery reads/writes in | |
| # the 80 ms after a resolved shutdown, and the bill arrived as merge-queue | |
| # evictions of two green PRs — because an `unref`'d timer lets a host | |
| # process exit while a test process stays alive through teardown. | |
| # WHY A GATE AND NOT THE REPAIRS: the trap is an ASYMMETRY. `start?()` IS | |
| # on the interface and does fire, so a `start`/`stop` pair reads symmetric | |
| # to a reviewer while only one half is ever called — which is how this | |
| # survived review in two packages and was found only by the evictions. Fix | |
| # the known instances and the next one arrives spelled `shutdown()` or | |
| # `close()`; a gate makes the class unreachable. Ruled FOR by the | |
| # `domain:devx` PM on the #9371 thread. | |
| # The roster is `stop` / `shutdown` / `close` / `dispose`, and what was | |
| # deliberately left OFF it (`disconnect`, `release`, `end`, `cleanup` …) | |
| # is argued in the script header and pinned as green cases by the | |
| # self-test — a roster chosen silently is the thing that goes stale. | |
| # The delegating alias (`stop()` calling `destroy()`, the #9371 repair | |
| # shape landed by PR #10375) stays green, and the self-test's positive | |
| # control is the REAL pre-#10375 revision of that same file rather than a | |
| # synthesised class. | |
| # The 11 instances that predate the gate are baselined shrink-only, each | |
| # pointing at #10371 which owns their repair; the list refuses new members | |
| # outright, so nothing can join it to get CI green. | |
| # Invoked as `node` rather than through a `pnpm check:*` alias, same as | |
| # the other `node scripts/…` steps in this lane: see the GATE INVOCATION | |
| # IDIOM note at the top of this file. | |
| # Runs its own --self-test first, and that leg is not decoration: this | |
| # gate computes its own population, so a scan that stopped matching would | |
| # print a confident green over a tree it never read (#4690's family). The | |
| # self-test's refusal cases are each PAIRED with a readable tree that | |
| # still returns a verdict, so "refuses unconditionally" cannot satisfy it. | |
| # 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 | |
| run: | | |
| node scripts/check-plugin-teardown-shape.mjs --self-test | |
| node scripts/check-plugin-teardown-shape.mjs | |
| # Resume-authority declaration gate (#5561, from #3823). The #3801 resume | |
| # gate keys on the SUSPENDED NODE, so it covers a pausing node type exactly | |
| # when that type's author remembered to declare `resumeAuthority`. #3823 is | |
| # what forgetting costs: ADR-0044 pointed a revise edge at a generic `wait`, | |
| # `wait` is legitimately 'any', and a pause standing in a service-owned | |
| # position inherited that value with nobody choosing it — an unaudited | |
| # resubmit plus a destroyed remote run. Until #5561 removed the schema | |
| # default the omission was not even observable (Zod filled the key, so it | |
| # parsed byte-identically to an explicit 'any'); now a pausing descriptor | |
| # that never states its authority cannot merge. Shipped sources only — | |
| # fixtures deliberately construct the omission to test the engine's | |
| # registration warning. Static AST, no build needed, so it belongs in this | |
| # job. Runs its own --self-test first: the detector can be broken while | |
| # every descriptor is fine, and a scan that stops matching would report OK | |
| # while reading nothing (#4868's family). | |
| - name: Resume-authority declaration gate | |
| run: pnpm check:resume-authority-declared | |
| # driver-memory census gate (#6664, from #5704 / #5499). #5499 froze | |
| # investment in `@objectstack/driver-memory`; #5704 migrated the project's | |
| # test backends to sqlite `:memory:` and ruled that one test file keeps the | |
| # driver on purpose. The programme's enforcement was a PROSE CENSUS — a | |
| # sentence in that file claiming it was the only permanent test consumer in | |
| # the repository, backed by #5704/#5784 renaming every look-alike local stub | |
| # to `makeStubDriver` so a grep would land on real consumers only. The grep | |
| # is a good handle; the sentence describing what it should find is not. It | |
| # expired silently the first time the hand-edit step was skipped: #6468 | |
| # (PR #6553) added a second importer with no ruling block, named nowhere in | |
| # the census it invalidated, and the two ways that misleads are opposite — | |
| # delete the other file's schemaless arm as a "leftover", or take it as | |
| # implicitly covered so a THIRD arrival is never disposed of either. The | |
| # census is now a ledger (scripts/driver-memory-census.ledger.json) with a | |
| # gate reading it: every module binding and every package.json declaration | |
| # must be ledgered, every ledger entry must still be live, each ruled file | |
| # must still carry its ruling markers, and — the half a sentence can never | |
| # do for itself — each must state the ruled COUNT verbatim, so changing the | |
| # set makes both sentences fail until they are rewritten. Static AST over | |
| # tracked sources (positions, not text: a comment naming the package and the | |
| # bundler externals entry in packages/runtime/tsup.config.ts are mentions, | |
| # not arrivals), no build needed, so it belongs in this job. Runs its own | |
| # --self-test first: the detector can be broken while the census is fine, | |
| # and a scan that stops matching would report OK while reading nothing | |
| # (#4868's family — DISCOVERED refuses an empty scan for that reason). | |
| # It polices the retirement programme's bookkeeping; it is not investment in | |
| # the driver, which stays frozen under #5499. | |
| - name: driver-memory census gate | |
| run: pnpm check:driver-memory-census | |
| # Merge-driver wiring gate (#6008, from #4675 / #4868). `merge=os-regen` | |
| # is what stops generator-owned artifacts (spec-changes.json, the ADR-0087 | |
| # registries, the api-surface baselines) from text-merging into a | |
| # plausible-looking wrong file: the driver defers the path and records it, | |
| # and `.githooks/pre-commit` refuses the commit until the regeneration has | |
| # actually happened. Both halves are CONFIG — `.gitattributes` routing on | |
| # one side, the table in scripts/regen-artifacts.mjs on the other — and | |
| # they drift apart in either direction with nothing failing: a path | |
| # declared in the table but unmapped still text-merges, and a path mapped | |
| # but undeclared leaves the merge CONFLICTED with no explanation. The | |
| # self-test reconciles them both ways, checks the gen:/check: names the | |
| # driver advises still resolve, that the hook is executable IN THE INDEX | |
| # (git ignores a non-executable hook and says only `advice.ignoredHook`), | |
| # that the driver registered in THIS clone resolves here (#4868: it | |
| # pointed at a deleted worktree for weeks while every other check stayed | |
| # green), and proves the behaviour end to end against real git. The | |
| # second half pins the staleness rules' dangerous direction — "says fresh | |
| # when stale". | |
| # It ran nowhere until now: `pre-commit` runs check-regen-pending's MAIN | |
| # path (which exits immediately with no pending marker), never | |
| # `--self-test`, and no workflow named the command — so the reconciliation | |
| # was verified only when someone happened to run the root script by hand | |
| # (#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 | |
| run: pnpm check:merge-driver | |
| # ADR-0122 type-alias convention gate (#5551). `packages/spec` spelled the | |
| # same idea two ways for its whole life — 1384 bare aliases meaning the | |
| # PARSED state, 86 meaning the AUTHOR state — and three first-hand sources | |
| # each called the 8-file minority "the house convention". ADR-0122 settles | |
| # it (bare = author state, `XParsed` = parsed state) and lands phase 1 | |
| # additively: an `XParsed` wherever the parsed state is a distinct type, so | |
| # the major-window flip of the bare names has a migration target everywhere | |
| # it changes meaning. This gate is what stops that coverage from decaying — | |
| # a new bare `z.infer` alias must either declare its `XParsed` or be pinned | |
| # isomorphic in `packages/spec/src/type-alias-convention.pin.test.ts`, where | |
| # tsc proves the exemption rather than a comment asserting it. Static text | |
| # scan, no build needed, so it belongs in this job. Runs its own | |
| # --self-test first: the detector can be broken while every alias is fine, | |
| # and a scan that stops matching would report OK while reading nothing | |
| # (#4868's family). | |
| - name: Spec type-alias convention gate (ADR-0122) | |
| run: pnpm check:spec-parsed-alias | |
| # Read-side tenant chokepoint gate (#6792, from #3724 / #6577). | |
| # `SqlDriver.applyTenantScope()` owns read-side tenant isolation for the | |
| # whole SQL family — the tenantId early-out, the no-tenant-field early-out, | |
| # the NULL-org platform-row rule (#2734) and the ADR-0105 D2 union posture | |
| # (#3623). Its own docstring claimed "every CRUD method routes through it". | |
| # Nothing checked that, and it was FALSE for as long as it had existed: | |
| # three doors built through `getBuilder()` and never arrived — | |
| # `findWithWindowFunctions` (ROWS: a caller passing `tenantId` got every | |
| # tenant's rows, measured `[a1,a2,b1,b2,p1]` against `find()`'s | |
| # `[a1,a2,p1]`), `analyzeQuery`/`explain` (a PLAN for a statement `find()` | |
| # would not run — the same defect #6577 fixed on these methods one builder | |
| # line lower), and `distinct` (every tenant's values for one column). | |
| # | |
| # The third is the argument for gating rather than fixing. It was in NO | |
| # card: #6792 asserts the opposite — that `distinct` is among the 13 scoped | |
| # sites — and the triage comment and two rounds of measurement all | |
| # inherited that sentence without re-deriving it. The 13th read site is | |
| # `aggregate()`. Two of the three doors were found by a human reading the | |
| # file for another reason; the third was found only by measuring, which is | |
| # the thing a prose invariant can never do for itself. | |
| # | |
| # Keyed on the BUILDER, not the method signature. #6792 sketches "every | |
| # method taking `(object, …, options)` and returning rows"; that criterion | |
| # is measurably too narrow — `distinct(object, field, filters, options)` | |
| # takes no query and `analyzeQuery` returns a plan, so it misses two of the | |
| # three. `getBuilder()` is the single constructor of every statement this | |
| # driver sends, so every builder is classified and one that cannot be | |
| # classified is an error, never a default (#4690's family). Insert builders | |
| # are exempt structurally, not by name: write-side tenancy is | |
| # `injectTenantOnInsert`. | |
| # | |
| # Static AST over three files, no build needed, so it belongs in this job. | |
| # Runs its own --self-test first, in both directions — the detector can be | |
| # broken while every door is fine, and a scan that stops matching would | |
| # report OK while reading nothing. | |
| - name: Read-side tenant chokepoint gate | |
| run: pnpm check:tenant-chokepoint | |
| # Every committed `pnpm --filter <name>` must name a real workspace | |
| # package (#10853). | |
| # | |
| # ⛔ `pnpm --filter <name>` EXITS 0 when the filter matches nothing: | |
| # | |
| # $ pnpm --filter @objectstack/definitely-not-a-package test; echo $? | |
| # No projects matched the filters in "..." | |
| # 0 | |
| # | |
| # So a step here, a package.json script or a `scripts/**` helper can name | |
| # a package that does not exist and stay GREEN forever, measuring nothing | |
| # and reporting success. `cmd > log 2>&1; ec=$?` captures the 0 perfectly; | |
| # the log even says what happened and nothing reads it. Same family as the | |
| # `--`-before-vitest-args trap: exit 0, nothing measured, output that reads | |
| # like success — #4690, sitting underneath the command used to PROVE | |
| # results. | |
| # | |
| # This gate reads SPELLINGS THAT ARE CHECKED IN, and that is the whole of | |
| # its claim. It cannot see a filter an agent types at a prompt, which is | |
| # the population that actually bit; that half is covered, only for callers | |
| # of the shared verify lock, by `scripts/pm/os-verify-lock.sh`'s filter | |
| # preflight. Disjoint populations, neither a substitute for the other. | |
| # | |
| # Reads package.json, pnpm-workspace.yaml and text under `scripts/**` and | |
| # `.github/workflows/**`. No build, no pnpm invocation, no network, | |
| # sub-second. The `check:` key runs the shared resolver's --self-test | |
| # first, then the gate's, then the gate: a checker whose resolver is | |
| # broken has no verdict worth printing. | |
| - name: Every committed pnpm --filter names a real package | |
| run: pnpm check:pnpm-filter-targets | |
| # The sibling of the gate above, and the OTHER half of the family its | |
| # comment names (#10166). There the `--filter` selector matched nothing; | |
| # here the filter matched, the script ran, and the ARGUMENTS were lost. | |
| # | |
| # ⛔ Hand a vitest-backed script its arguments after a bare separator and | |
| # the WHOLE package suite runs. pnpm forwards the separator verbatim into | |
| # the child argv (npm and turbo both strip it — pnpm is the odd one out), | |
| # and vitest's cac parser then discards EVERYTHING after it, options and | |
| # positionals alike, in silence. The file pattern and `--maxWorkers` die | |
| # together, so the concurrency discipline is inert in the same breath. | |
| # Measured on the card: 415 files / 11045 tests / 358s under the shared | |
| # verify lock, where the intended run was one file in 655ms — green, exit | |
| # 0, reading exactly like the narrow run that was asked for. | |
| # | |
| # ⚠️ The broken spelling is written out in the GATE'S header, not here. | |
| # The first draft of this comment spelled it, and the gate red on this | |
| # file at its own wiring commit — which is the objection a corpus gate was | |
| # rejected on once before ("it would fire on the counter-example the | |
| # corrected instruction MUST contain"). That objection is real; the answer | |
| # is `COUNTER_EXAMPLE_FILES` in the gate, and the reason this file is not | |
| # in it is that a whole workflow is too coarse a thing to exempt — a | |
| # broken spelling here would EXECUTE. | |
| # | |
| # ⚠️ The violating population is EMPTY today (#11425 landed the corrected | |
| # spelling), so this step's job is to keep it empty — and the gate says so | |
| # in its own verdict line rather than printing a bare "0 violations". It | |
| # reports how many separators it JUDGED, not just how many it failed: 5 on | |
| # the tree it landed against, four of which a naive bare-`--` selector | |
| # would have RED (this workflow's own turbo dogfood shard, and the three | |
| # documented `pnpm dev -- --fresh` spellings in AGENTS.md). Its rule is | |
| # vitest-BOUND, and the vitest-backed script names are derived from the | |
| # workspace rather than typed here. | |
| # | |
| # Reads text under `.claude/**`, `skills/**`, `scripts/**`, | |
| # `.github/workflows/**` and every tracked AGENTS.md / CLAUDE.md, plus the | |
| # workspace manifests for the derivation. No build, no network, | |
| # sub-second. The `check:` key runs `--self-test` first: it drives this | |
| # same sweep RED over a temp tree on disk, which is what carries | |
| # non-vacuity while the real population is zero. | |
| - name: No committed command forwards args to vitest through a bare -- | |
| run: pnpm check:agent-test-spelling | |
| # Every `<pkg>#<task>` override in turbo.json must name a package the | |
| # workspace HAS and a script that package RUNS (#12046). | |
| # | |
| # ⛔ Turbo does NOT refuse either miss. Measured on turbo 2.10.10 against a | |
| # fixture workspace, each case a real `turbo run <task> --dry=json`: | |
| # | |
| # @fx/nope#build (no such package) -> exit 0, no diagnostic, key | |
| # never reaches the graph | |
| # @fx/a#typecheck (package has no such | |
| # script) -> exit 0, task shown in the | |
| # dry-run graph, never runs | |
| # | |
| # So a task-graph edit meant to change what CI builds, orders or caches can | |
| # configure NOTHING while every signal its author has — exit code, dry-run | |
| # output, this whole workflow — reads as landed. The stale direction is the | |
| # same silence: rename a package or drop a script and the entry that used | |
| # to configure it goes quietly inert. | |
| # | |
| # `dependsOn` is deliberately out of scope here: turbo already refuses an | |
| # unresolvable one loudly (exit 1, `x Could not find "@fx/a#prebuildxyz"`), | |
| # and re-checking it would be this gate claiming a population the tool that | |
| # owns it polices better — the #11556 failure this card was filed against. | |
| # | |
| # The gate is also the first thing in the repo that DECLARES turbo.json as | |
| # a path population, in the `turbo.json/**` subtree spelling the dispatch | |
| # derivation can match. Before it, a card whose surface was turbo.json | |
| # derived zero gate families: the one gate that parses the file | |
| # (check:cross-package-test-inputs) reaches it as a bare filename, which | |
| # `extractWatchHints` refuses as too generic. | |
| # | |
| # Reads turbo.json and the workspace manifests. No build, no pnpm | |
| # invocation, no network, sub-second. The `check:` key runs `--self-test` | |
| # 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 | |
| run: pnpm check:turbo-task-graph | |
| # 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 | |
| # contract, and is the thing the merge queue actually requires — read it | |
| # before moving a step between lanes. | |
| # | |
| # THIS LANE BUILDS NOTHING, and that is the point: every gate in it already | |
| # ran BEFORE the build step in the old serial job, so each one is known to | |
| # read `src/` (via tsx) or a package.json and need no `dist/`. Pulling them | |
| # out means the ~78s of cheap, high-signal gates no longer sit behind a | |
| # 5½-minute build they never needed — they now report in about two minutes. | |
| # ⚠️ Do not add a step here that reads a built `dist/`: it would find a | |
| # missing or stale one. Build-dependent gates belong in the lanes below. | |
| typecheck-source-gates: | |
| name: Type Check · source gates | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v7 | |
| with: | |
| # The authorable-surface deletion gate (#4650) below anchors on the | |
| # MERGE BASE of HEAD with origin/main — that is the only anchor under | |
| # which "a key this PR deleted" and "a key main gained since the fork | |
| # point" are different facts. A shallow clone has no walkable | |
| # ancestry, so `merge-base` fails and the gate falls back to | |
| # origin/main's TIP, where those two facts collapse into one and the | |
| # SECOND one is reported as the first: #6359 had PR #6356 (which | |
| # touches no spec file at all) go red for "deleting" | |
| # ui/BulkActionDef:requiredPermissions — a key main had just ADDED. | |
| # | |
| # Same line as the ESLint job above, opposite failure mode, and that | |
| # is why it is spelled out here rather than cross-referenced: shallow | |
| # degrades the slot-lookup ratchet to "not verified" (a false GREEN), | |
| # and degrades this gate to a false RED on an innocent PR. | |
| fetch-depth: 0 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: '22' | |
| - name: Setup pnpm | |
| uses: ./.github/actions/setup-pnpm | |
| - name: Get pnpm store directory | |
| shell: bash | |
| run: | | |
| echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV | |
| - name: Setup pnpm cache | |
| uses: actions/cache@v6 | |
| with: | |
| path: ${{ env.STORE_PATH }} | |
| key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} | |
| restore-keys: | | |
| ${{ runner.os }}-pnpm-store-v3- | |
| # This job runs the full workspace build below ("Build workspace | |
| # packages"); without a restored turbo cache that step rebuilt every | |
| # package from scratch on every run (~4½ min) while ci.yml's jobs — which | |
| # do carry this cache — finished the same build in under a minute. Same | |
| # key scheme as ci.yml so the fallback prefix can also hit main's caches. | |
| # Restore-only on PRs (same policy as ci.yml): PR-side saves churned the | |
| # 10 GB Actions cache pool and evicted the main seeds; only main pushes | |
| # save (the "Save Turbo cache" step at the end of the job). | |
| # | |
| # The build-core fallbacks are #5401. Only main writes, so a merge_group | |
| # entry always restores by PREFIX — and this job's own namespace has a | |
| # window where that prefix matches nothing: the previous main generation | |
| # is already gone while the current one is still being written (measured | |
| # 2026-08-05, run 30985524210 — all three keys reported "Cache not found" | |
| # at 07:34:18Z and the workspace build went 4s -> 4m54s, while the very | |
| # same queue entry's ci.yml jobs hit `-main-f417863f` in that same | |
| # minute). build-core's entry is the one that survives that window: its | |
| # main-push save had completed at 07:32:50Z, 88s earlier, and `pnpm | |
| # build` (turbo run build --filter=!@objectstack/docs) is a SUPERSET of | |
| # this job's build step, so the entries it carries are the ones this | |
| # build needs — same reasoning as the fallbacks the Temporal | |
| # Conformance / Dogfood Verify / Console Pin jobs already carry in | |
| # ci.yml, whose spelling these two lines match exactly. | |
| # | |
| # Partial by construction, and that is the honest expectation: it warms | |
| # the `build` tasks, not the `typecheck` tasks (build-core never runs | |
| # those, so nothing seeds them outside this namespace). | |
| # | |
| # ⚠️ THE NAMESPACE IS THE LITERAL `typecheck`, NOT `${{ github.job }}`. | |
| # It used to be the expression, back when one job carried this whole | |
| # family and the two spellings happened to agree. They no longer do: the | |
| # family is four parallel lanes (`typecheck-source-gates`, | |
| # `typecheck-workspace`, `typecheck-debt`, `typecheck-consumers`) plus | |
| # the `typecheck` aggregator, so `github.job` would give each lane its | |
| # OWN namespace — four prefixes nothing has ever written, and every one | |
| # of main's seeds unreachable. That failure is silent and expensive in | |
| # exactly one direction: the restore reports "Cache not found", the lane | |
| # cold-builds, and the run is still GREEN — you only see it as a job that | |
| # got slower, which is the thing this card exists to fix. The lanes share | |
| # one namespace on purpose, and it is spelled the same as the key main's | |
| # save writes below, so the seeding relationship survives the split | |
| # unchanged. | |
| - name: Restore Turbo cache | |
| uses: actions/cache/restore@v6 | |
| with: | |
| path: .turbo/cache | |
| key: ${{ runner.os }}-turbo-typecheck-${{ github.ref_name }}-${{ github.sha }} | |
| restore-keys: | | |
| ${{ runner.os }}-turbo-typecheck-${{ github.ref_name }}- | |
| ${{ runner.os }}-turbo-typecheck- | |
| ${{ runner.os }}-turbo-build-core-${{ github.ref_name }}- | |
| ${{ runner.os }}-turbo-build-core- | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| # Coverage meta-gate (#4311): 66 of 77 workspace packages build with tsup | |
| # (esbuild transpile, no type check) and `vitest run` does not type-check | |
| # either, so a package without a `typecheck` script has NO tsc reading its | |
| # src/ or its tests at all — 380 real errors had accumulated that way, | |
| # invisible to every green build. This gate makes the hole itself the | |
| # failure: every package either declares `typecheck` (run by the turbo | |
| # step below) or carries a measured DEBT/EXEMPT entry in the script's | |
| # ledger, reconciled in both directions so the ledger can only shrink. | |
| # Reads package.json files only; no build, sub-second. The other half of | |
| # this gate — re-running tsc against each ledger number — needs the built | |
| # 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 | |
| run: pnpm check:type-check-coverage | |
| # Conformance meta-gate (#4363): `spec/src/data/*-conformance.ts` holds the | |
| # shared case-sets that exist so the independent drivers answer ONE | |
| # standard, and three separate changesets claimed a future driver "is held | |
| # to this by a gate rather than by remembering it". There was no gate — | |
| # nothing obliged a driver to import them — and the matrix had three holes, | |
| # one of them in the case-set whose changeset made the claim. Same shape as | |
| # the ratchet above: every (driver × case-set) cell is covered or carries a | |
| # measured DEBT/EXEMPT entry, reconciled in both directions, plus a | |
| # 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 | |
| run: pnpm check:driver-conformance | |
| # Stall-guard self-test (#4250). scripts/run-with-stall-guard.mjs is what | |
| # turns a frozen Test Core into a labeled red; six jobs across five | |
| # workflows now route their test steps through it. But it only executes its | |
| # interesting path during an event that is rare (~5-10% of days) and not | |
| # reproducible on demand — so between real stalls there was nothing at all | |
| # asserting it still works, and a guard that has never fired is | |
| # indistinguishable from a guard that does not. A refactor could have | |
| # disarmed CI's only stall detector and every run would have stayed green. | |
| # | |
| # This runs the guard against SYNTHETIC stalls — an idle hang, a | |
| # sync-spinning hang, a hang with no output at all, a SIGTERM-trapping | |
| # descendant — and asserts the verdict, the idle/ON-CPU classification, the | |
| # SIGUSR2 stack harvest (including the "no report = blocked event loop" | |
| # inference), full process-group teardown, and the negative direction: a | |
| # 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 | |
| run: pnpm check:stall-guard | |
| # The stall guard's CI-wiring invariant (#11916). The step above proves the | |
| # guard still WORKS; this one proves it can still be HEARD. The guarantee — | |
| # a stalled job says so instead of sitting in_progress until the job | |
| # timeout — holds only while the guard's own kill budget lands before the | |
| # enclosing job's `timeout-minutes`, and nothing read that relationship: | |
| # measured on f907fbe9e, the only file under `scripts/` mentioning | |
| # `timeout-minutes` at all is the guard itself, in two header COMMENTS | |
| # explaining it. | |
| # | |
| # Its regression is invisible on green runs, which is what makes it a gate | |
| # rather than a review note: the guard is observable only when it fires, so | |
| # a budget that can no longer fire in time scores identically to one that | |
| # works. Lowering a job's timeout, raising --stall-minutes, or adding a | |
| # guarded step to a short job each break it in one line, silently. | |
| # | |
| # Walks `.github/workflows/**`, resolves each guard-wrapped step's | |
| # effective cap (explicit --stall-cap-minutes, else DEFAULT_CAP_MULTIPLE × | |
| # the window — both READ out of the guard, never copied), and reds unless | |
| # the cap clears the binding timeout by at least one stall window. It | |
| # REFUSES (exit 2) rather than passing when the sweep finds no guarded step | |
| # at all: "every guarded step has headroom" is vacuously true of an empty | |
| # population. Reads workflow text only; no build, no network, sub-second. | |
| - name: Stall-guard budget invariant | |
| run: pnpm check:stall-guard-budget | |
| # The dev-preflight gate's self-test — and deliberately only HALF of that | |
| # gate (#8170, the second and only other member of #8110's split-gate | |
| # family). Same neighbour as the step above for the same reason: both are | |
| # self-tests of a guard whose real path CI does not exercise. | |
| # | |
| # The npm script of the same name is `--self-test && the scan`, and the | |
| # SCAN half is genuinely CI-hostile. Its question is "is THIS WORKING TREE | |
| # built, and is the one staleness-lying artifact current" — a precondition | |
| # for `pnpm dev` on a human's long-lived tree. Re-measured on a fresh | |
| # worktree before any install or build: exit 1, "The workspace is not built | |
| # — 67 of 67 workspace packages declare an entry point under dist/ that is | |
| # not on disk". In a job that has not built, that is a hard false red about | |
| # a precondition CI does not have; in a job that just built, it is a | |
| # tautological green. It correctly stays out, and it was taking the half | |
| # that CAN run with it — the gate sat in the gate list reading as coverage | |
| # while its scan ran on every human's `pnpm dev` and nothing kept that scan | |
| # honest. | |
| # | |
| # The `--self-test` half is a different animal. Its fixtures are SYNTHETIC: | |
| # it materializes throwaway workspaces in a temp dir and drives the gate's | |
| # own `inspect`/`report`/`stamp` to all 16 verdicts. No network, no git, no | |
| # node_modules, no workspace state at all — measured green in ~0.11s in a | |
| # `--depth 1` clone carrying no remote-tracking ref for main. | |
| # | |
| # WHAT REDDENS IT, and where this differs from its sibling. #8110's step | |
| # cannot go red alone (its specimen is derived from the frame documents, so | |
| # a structural change reddens the sync gate above it first). This one CAN, | |
| # and that is fine: its only input is this one script's own code, so the | |
| # only PR that reaches it red is a PR that edits that script. Measured | |
| # adversarially — rewording the stale-dist red message from "LIES" reds this | |
| # step alone with `stale/says-it-lies: expected true, got false`, naming the | |
| # assertion in the very file the PR is editing, so no sibling gate needs to | |
| # diagnose it. The other direction was measured too: a legitimate change | |
| # elsewhere cannot redden it — dropping `--stamp` from packages/spec's build | |
| # script (which DOES red the scan half) leaves this step green, because the | |
| # fixtures are synthetic. That is also this step's honest boundary: it | |
| # vouches for the gate's verdict paths, never for this workspace. | |
| # | |
| # Invoked as `node`, NOT through the npm script, precisely because that | |
| # 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) | |
| run: node scripts/check-dev-prereqs.mjs --self-test | |
| - name: Type check (@objectstack/spec) | |
| run: pnpm --filter @objectstack/spec exec tsc --noEmit | |
| # Meta-gate (#4203): the `check:generated` aggregate reconciles its | |
| # GATED/NO_GENERATOR ledger against packages/spec/package.json on every run, | |
| # in both directions, so a `check:`/`gen:` script nobody classified fails | |
| # instead of quietly dropping out of coverage. But the reconciliation only | |
| # ran where the aggregate ran — locally. CI runs the gates as individual | |
| # steps, so a PR adding an unclassified script kept every CI gate green | |
| # while the wrapper AGENTS.md prescribes exited red on `main`, running zero | |
| # gates. Twice in three days: #4177 (`check:variant-docs`, fixed only when | |
| # #4194 collided with the same wall) and #4232 (`check:strictness-ledger`, | |
| # caught while wiring this step). It could not live in ci.yml's | |
| # `check-generated` job — that job was gated on a `generated` paths filter | |
| # that never watched packages/spec/package.json, the one file every | |
| # offending PR must touch. That job is gone now (see the four gates below); | |
| # 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" | |
| run: pnpm --filter @objectstack/spec check:generated --reconcile-only | |
| # The last four artifact gates, moved here from ci.yml's `check-generated` | |
| # job when that job and its `generated` paths filter were deleted (#4291). | |
| # | |
| # The filter was a hand-maintained duplicate of each gate's input set, and | |
| # nothing reconciled the two. It drifted three times on record, each found | |
| # by accident and written up in a comment rather than gated: #2584 moved a | |
| # generated page and the filter kept watching the old path, so hand-edits | |
| # went unchecked for months; #3855 listed specific spec paths but no schema | |
| # dirs, so `check:authorable-surface` went dormant on exactly the PRs that | |
| # remove an authorable key; and the json-schema manifest — the #2978 | |
| # ratchet that is the ONLY durable record of every emitted schema, since | |
| # json-schema/ is gitignored — was never watched at all, so a PR retiring a | |
| # key from it skipped its own verifier. Six gates had already escaped to | |
| # this job one at a time, each with a comment saying the filter had failed | |
| # it; these four finish the migration and retire the duplicate ledger. | |
| # | |
| # Affordable because the work was already being done here: one run of | |
| # scripts/build-schemas.ts serves both `check:authorable-surface` and the | |
| # `check:docs` gate below, and that whole step measures 4s in CI. All four | |
| # read source via tsx and need no build, so they run before the workspace | |
| # build, as the gates below already do. | |
| # | |
| # It used to be `check:docs` that ran `gen:schema`, and that is the half | |
| # #4723 removed: `gen:schema` is a generator, so a step named `check:` was | |
| # rewriting two TRACKED files (json-schema.manifest.json, | |
| # authorable-surface.json) whenever they were behind — silently repairing | |
| # locally what it was supposed to report, and running the same ~1600-schema | |
| # generation twice per job. The generation now happens once, in the | |
| # `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 | |
| run: pnpm --filter @objectstack/spec check:skill-docs | |
| # [#10096] The schema-free `/meta` URL-spelling data module | |
| # (src/meta-spelling/meta-url-data.generated.ts). Re-derives the | |
| # three-limb union from PLURAL_TO_SINGULAR + DEFAULT_METADATA_TYPE_REGISTRY | |
| # and fails on staleness OR on a manifest/derived spelling disagreement — | |
| # this step is the build-time enforcement home of what used to be a | |
| # module-load `assertMetaUrlSpellingsAgree()` (maintainer ruling | |
| # 2026-08-20: the assertion may move, never drop). Reads src via tsx, | |
| # 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 | |
| run: pnpm --filter @objectstack/spec check:meta-url-spelling | |
| - name: Check spec-changes.json is regenerated with the ADR-0087 registries | |
| run: pnpm --filter @objectstack/spec check:spec-changes | |
| - name: Check the protocol upgrade guide is regenerated with the ADR-0087 registries | |
| run: pnpm --filter @objectstack/spec check:upgrade-guide | |
| # The #4796 declaration-origin baseline. `export-origins/` records which | |
| # SOURCE DECLARATION every public entry point exports under every name, and | |
| # seventeen export-surface pin tests compare against it instead of each | |
| # building their own `ts.createProgram` inside a vitest case (~55s of | |
| # compilation per CI lap, and a non-deterministic timeout that ejected six | |
| # unrelated PRs from the merge queue in one night). | |
| # | |
| # A comparison is only as good as the thing compared, so THIS STEP IS WHAT | |
| # THOSE PINS MEAN. Without it a hand-edited or stale artifact would let | |
| # every one of them report success over a fiction — the dormant-gate shape | |
| # #4642 named. It belongs in this job for the same reason `check:docs` | |
| # does: no paths filter, required status check, so it cannot go dormant on | |
| # exactly the spec-only PRs that move the export surface. | |
| # | |
| # Reads `src/` via tsx and needs no build, so it runs before the workspace | |
| # build and fails in ~3s. The self-test in front of it pins the resolver's | |
| # own two edges (a re-export must read as ONE declaration; two declarations | |
| # sharing a name must NOT) — a broken resolver would otherwise write a | |
| # confidently wrong baseline and this step would agree with it. | |
| - name: Check the export-origins baseline resolves as recorded | |
| run: pnpm --filter @objectstack/spec check:export-origins | |
| # The authorable KEY surface — what a metadata author may write, which for | |
| # this platform is the third-party API. `api-surface/` records exported | |
| # names and `api-surface-signatures.json` hashes factory types as TypeScript | |
| # PRINTS them (a reference, never structurally expanded), so neither sees a | |
| # key added to or removed from a schema. #3883 removed three authorable keys | |
| # with every witness green; #3733 did it by accident. ADR-0059 §5 deferred | |
| # this gate until a narrowing actually slipped both — it has. | |
| # | |
| # ⚠ ORDER: this step must stay ABOVE the `check:docs` step below. Its | |
| # `--check` run of scripts/build-schemas.ts writes the gitignored | |
| # packages/spec/json-schema/ tree that `check:docs` renders from — the | |
| # explicit generation that replaced the `gen:schema` `check:docs` used to run | |
| # itself (#4723). Nothing here silently depends on that: `build-docs.ts` | |
| # 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 | |
| run: pnpm --filter @objectstack/spec check:authorable-surface | |
| # Generated-docs gate: content/docs/references/** is generated from the spec | |
| # by `gen:schema && gen:docs` and committed. Nothing regenerated it in CI, so | |
| # it drifted silently — #3076 added RowCrudActionOverride to the spec and the | |
| # public reference docs never learned the type existed. Regenerates and fails | |
| # on any difference. | |
| # | |
| # Deliberately lives in this job, not in ci.yml's "Build Docs": that job is | |
| # gated on a `docs` paths-filter that does not include packages/spec/**, so it | |
| # skips exactly the spec-only PRs that cause this drift (#3076 was one). This | |
| # job has no paths filter and is a required status check, so the gate cannot go | |
| # dormant. It reads src/ + json-schema/ via tsx and needs no build, so it runs | |
| # before the workspace build and fails in ~2s. | |
| # | |
| # It CONSUMES the json-schema/ tree the `check:authorable-surface` step above | |
| # generated; it no longer generates one itself (#4723). Two things came off | |
| # 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 | |
| run: pnpm --filter @objectstack/spec check:docs | |
| # Same class, same reasoning, different surface: skills/*/references/_index.md | |
| # and the objectstack-ui react-blocks contract are generated from | |
| # packages/spec/src and committed, and nothing regenerated them either. These | |
| # ship to third parties via `npx skills add objectstack-ai/objectstack`, so the | |
| # drift is served straight to consumers' agents — 6 of 113 schema pointers named | |
| # files the spec had already deleted or renamed. | |
| # | |
| # These moved here early, when ci.yml still had a filter-gated | |
| # `check-generated` job: its `generated` filter listed specific spec paths | |
| # (migrations/, conversions/, protocol-version) but no schema dirs, so a PR | |
| # touching src/data/** or src/ui/** — exactly what drives these two | |
| # artifacts — never triggered it, and it was not required either. That job | |
| # and its filter are gone (#4291); every artifact gate now lives here. | |
| # | |
| # Both read packages/spec/src via tsx and need no build (verified with every | |
| # workspace dist/ removed), so they run before the workspace build. check:skill-refs | |
| # additionally fails on a SKILL_MAP entry naming a file the spec no longer has: | |
| # 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 | |
| run: pnpm --filter @objectstack/spec check:skill-refs | |
| # Same third-party reason as the two gates above, applied to the SKILL.md | |
| # PROSE those gates never look at: they compare frontmatter and generated | |
| # indexes only, so the binding decision frame in the body — written out four | |
| # times across `.claude/**` (internal) and `skills/**` (published) — had no | |
| # gate at all. #5130 widened the internal frame from two axes to three and | |
| # the published mirror stayed at two for two days, which is the drift | |
| # "served straight to consumers' agents" in its purest form: third-party PM | |
| # agents escalated on two axes while this repo ruled on three. #5451 / | |
| # PR #5799 re-converged them by hand; this keeps them converged. | |
| # | |
| # Structure only — axis count, axis order, the binding sentence. The | |
| # published copy is deliberately generalized (#5451 route B: no startup | |
| # self-description, no this-repo issue numbers), so a wording comparison | |
| # would be permanently red and would get deleted instead of obeyed. | |
| # | |
| # Lives in this job for the same reason as its neighbours: no paths filter | |
| # and required, so it cannot go dormant on exactly the PR that breaks it — | |
| # and a `.claude/**` filter in particular would blind it, since half the | |
| # copies live there. It is a root script (not `--filter @objectstack/spec`) | |
| # because it reads no spec source and generates no artifact; see the | |
| # script header on layering. | |
| - name: Check the decision frame is in sync across its four copies | |
| run: pnpm check:skill-frame-sync | |
| # The SECOND skill-frame gate — and deliberately only HALF of it (#8110). | |
| # | |
| # `pnpm check:skill-frame-freshness` is `--self-test && the scan`, and only the | |
| # SCAN half is CI-hostile: it asks "is THIS WORKING TREE's frame current with | |
| # origin/main?", which on a fresh merge ref is a tautological green, and on any | |
| # PR that legitimately edits the frame would be a false red — "differs from main" | |
| # is that PR's whole purpose. That script's own header ("WHERE IT RUNS") argues | |
| # it, and the scan stays out of CI for exactly that reason. It is NOT wired here. | |
| # | |
| # The `--self-test` half is a different animal. It DERIVES a coherent two-axis | |
| # specimen from this tree's own frame documents, commits it and the real | |
| # documents into throwaway git repos whose `origin` points at a path that does | |
| # not exist, and asserts 12 verdicts against the gate's own code. No network, no | |
| # origin/main, no history: measured green in a `--depth 1` clone carrying no | |
| # remote-tracking ref at all, in ~1.3s. So the half that CANNOT run in CI was | |
| # keeping the half that CAN out of it, and the price was measured on #8024 — | |
| # the fixture had stranded TWICE, neither time noticed, while the gate sat in | |
| # the gate list reading as coverage and protecting nothing. | |
| # | |
| # This step cannot redden a PR that edits the frame on purpose: the specimen is | |
| # re-derived from whatever the frame currently is, so a reword, a reflow or a | |
| # principles-only rewrite carries the fixture with it (verified adversarially | |
| # before wiring this — rewording axis prose across all three frame files keeps | |
| # both gates green). What DOES redden it is a STRUCTURAL change — an axis added, | |
| # removed, or its declaring sentence reworded past the shared anchors — and that | |
| # reddens `check:skill-frame-sync` directly above with the same diagnosis, which | |
| # the self-test's own failure message names as the thing to fix first. No PR can | |
| # reach this step red with the step above green. | |
| # | |
| # Invoked as `node`, NOT `pnpm check:skill-frame-freshness`, precisely because | |
| # that npm script would drag the scan half in with it — the same shape as the | |
| # `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) | |
| run: node scripts/check-skill-frame-freshness.mjs --self-test | |
| # The third gate over SKILL.md, covering the one line the other two cannot see | |
| # (#5331). check:skill-docs / check:skill-refs compare generated artifacts and | |
| # check:skill-examples typechecks `os:check` blocks; none of them reads the | |
| # `compatibility:` frontmatter line. That blind spot is measured, not theoretical: | |
| # nine of ten skills declared `@objectstack/spec 16.x` for an entire major cycle | |
| # while teaching 17's capabilities, with all three gates green throughout (#5245). | |
| # #5245 corrected the values by hand; this reconciles them against | |
| # packages/spec/package.json on every run so they cannot drift again. | |
| # | |
| # Note the contract is the wording that actually LANDED for #5245 — an exact | |
| # `<major>.x` pin. If the wording is ever changed to an unpinned range, this gate | |
| # goes RED rather than quietly matching nothing (#4690): a gate that cannot find | |
| # its input must fail, never skip. | |
| # | |
| # Same job and same reasons as its neighbours: no paths filter and required, so it | |
| # cannot go dormant on exactly the PR that breaks it — and a `skills/**` filter in | |
| # particular would blind it to the other half of its input, packages/*/package.json, | |
| # where a major bump is what makes the declarations stale in the first place. | |
| - name: Check SKILL.md compatibility declarations match the workspace majors | |
| run: pnpm check:skill-compatibility | |
| # The fourth gate over the .claude/ file tree, and the first to read | |
| # .claude/agents/ at all (#6803). An agent definition that declares no `model:` | |
| # INHERITS the dispatching session's model, so the role's tier is set by whoever | |
| # dispatched it and when — a property of the caller's ambient state rather than | |
| # of the role. Measured cost: four devs dispatched from one smaller-model session | |
| # all died on the same shared quota wall, three leaving uncommitted and wholly | |
| # ungated work in their worktrees (#6686). The failure is batched, and invisible | |
| # to the dispatcher, whose pre-dispatch checks never ask what model the batch runs. | |
| # | |
| # The caller-side half of this rule already existed and did not hold: pm-dispatch | |
| # SKILL.md §5 has said 'pass `model: "opus"` on every dev dispatch' since before | |
| # that incident, and a seat that had read it still dispatched twelve agents in a | |
| # row without passing it. That is why the assertion is over the DEFINITION, which | |
| # holds regardless of caller compliance, rather than more prose at the call site. | |
| # | |
| # It asserts PRESENCE, never which tier — the tier is maintainer policy (#6803 | |
| # puts changing it out of scope), and a policy change should not have to edit a | |
| # gate to land. `model: inherit` stays legal for a role that genuinely follows its | |
| # caller, but only with a written justification the script re-checks against the | |
| # live file, so a deliberate inherit is a recorded decision and a silent one is red. | |
| # | |
| # Same job and same reasons as its neighbours: no paths filter and required, so it | |
| # cannot go dormant on exactly the PR that breaks it — and a `.claude/**` filter | |
| # would blind it to the PR that adds a new agent definition anywhere else. | |
| - name: Check every agent definition declares a model | |
| run: pnpm check:agent-model-declared | |
| # The #8435 ratchet-remedy authority convention, enforced farm-wide instead of | |
| # gate by gate. A gate whose second remedy is editing a shrink-only ratchet must | |
| # either mark that path `⛔ MAINTAINER-ONLY` or turn it down outright; six gates | |
| # do (PR #8517, #8539, #8549) and nothing stopped the seventh from not. | |
| # | |
| # A shared helper module was the obvious route and is the wrong one: a module is | |
| # reachable only from gates that import it, so it standardises the gates that | |
| # already agreed and is blind to the next one. #8538 is the proof — the sixth | |
| # instance was found by sweeping source text, and that file imports nothing to be | |
| # found by. So this sweeps `scripts/*.{mjs,mts}` — never `*.mjs`, which is the | |
| # blind spot that made #8538 necessary in the first place. | |
| # | |
| # Runs its own --self-test first, and the self-test carries a corpus-scale | |
| # positive control: a detector that quietly stopped matching would otherwise | |
| # report a clean farm while reading nothing, which is the exact failure the | |
| # convention it enforces is about. Static text only — no build, so it belongs in | |
| # this pre-build group. No paths filter, for the standard reason: a filter on | |
| # scripts/** would go dormant on the PR that reworded the convention elsewhere. | |
| - name: Ratchet-remedy authority convention (#8435) across the gate farm | |
| run: pnpm check:ratchet-remedy-authority | |
| # The published `skills/**` bundle is loaded WHOLE into customer agent context | |
| # windows, so its length is a per-token cost paid again in every customer | |
| # session — and until #10473 nothing priced it. The maintainer ruling that | |
| # established the principle (2026-08-21, on PR #10402) had no mechanism behind | |
| # it, leaving a reviewer's memory as the only control; the `.claude/**` line | |
| # ratchet had proven by then that a prose-only length rule gets rediscovered | |
| # the expensive way. | |
| # | |
| # Runs its own --self-test FIRST, then the scan. The self-test is the half that | |
| # keeps the scan meaningful: it drives the verdict paths over fixtures, so a | |
| # counting or enumeration regression fails here rather than reporting a clean | |
| # bundle while measuring nothing. The scan prints the bundle price tag on | |
| # SUCCESS as well as failure, which is the visibility half of #10473 — every | |
| # PR's log carries each file's distance from its ceiling. | |
| # | |
| # Invoked as `node`, NOT through an npm script — see the GATE INVOCATION | |
| # IDIOM note at the top of this file; same shape as the two | |
| # `node scripts/…` steps earlier in this lane. Static text only, no build, so | |
| # it belongs in this pre-build group. No paths filter, for the standard reason: | |
| # a filter on skills/** would go dormant on the PR that edits the ceilings. | |
| - name: Published skills token ratchet (#10473) + its self-test | |
| 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 | |
| run: pnpm --filter @objectstack/spec check:react-blocks | |
| # The shipped `objectstack.manifest.json` files declare TemplateManifestSchema | |
| # as their `$schema`, and until #7319 nothing read that claim: both consumers | |
| # take the file as raw JSON (create-objectstack does not depend on the spec at | |
| # all), so the schema and the artifact drifted twice without a red build — | |
| # #6861's silently stripped `namespace`, then a required `manifestId` the blank | |
| # template has never carried. One parse per shipped manifest closes the class. | |
| # | |
| # Reads `src/` and the template trees, so it needs no build and belongs in this | |
| # pre-build group with the other source audits. No paths filter and required, | |
| # 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 | |
| run: pnpm --filter @objectstack/spec check:template-manifests | |
| # [#10274] The read-point records in packages/spec/src cite the objectui | |
| # commit their file:line anchors were counted in — "the objectui pin this | |
| # repo builds against (`.objectui-sha` = `<sha>`)". That sentence is what | |
| # makes a record RE-CHECKABLE, and it is the entire justification #9881 / | |
| # #9972 were filed on after #9397 spent a full dispatch cycle re-deriving | |
| # one read point from scratch. | |
| # | |
| # Nothing linked the sentence to the pin file. #10137 bumped `.objectui-sha` | |
| # and four records went on naming a pin this repo does not build against — | |
| # not WRONG (the anchors held) but UNVERIFIABLE, which is the exact state | |
| # they were written to end. Prose has no dependency edge, so the class | |
| # recurs silently on every bump. This gate is the edge. | |
| # | |
| # It checks only the ASSERTING spelling. `.objectui-sha` pin `<sha>` is a | |
| # dated record of where a past measurement was taken (#5010 absorbed at | |
| # `09987b68`, a renderer's branch semantics at `665661ab0932`) and a later | |
| # bump does not falsify it; forcing those forward would demand re-measuring | |
| # settled history on every bump. A sha named in NEITHER spelling fails | |
| # loudly rather than being skipped — an unrecognised spelling is how a | |
| # source scan loses a citation silently. | |
| # | |
| # Reads `src/` and the root pin file only — no build — so it belongs in | |
| # this pre-build source-audit group. Unfiltered and required: a paths | |
| # filter on packages/spec/** would go dormant on exactly the PR that moves | |
| # `.objectui-sha`, which is the PR this exists to catch. | |
| - name: Check spec pin citations still name the pin this repo builds against | |
| run: pnpm --filter @objectstack/spec check:objectui-pin-citations | |
| # #11344. `packages/spec/llms.txt` is hand-kept, has no generator, and | |
| # SHIPS in the published tarball as context for AI consumers — the | |
| # audience least able to notice staleness and most likely to turn a claim | |
| # into code. When this landed the file advertised eleven symbols that | |
| # existed in no entry point (`IUIService` among them, removed in v11), two | |
| # packages that did not exist, and a schema table whose heading disagreed | |
| # with the sum of its own rows. | |
| # | |
| # Reads `src/`, the checked-in `api-surface/` shards, the package manifest | |
| # and pnpm-workspace.yaml — no build — so it belongs in this pre-build | |
| # source-audit group beside its nearest sibling above. Unfiltered on | |
| # purpose: the counts are measured against the whole workspace and against | |
| # 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 | |
| run: pnpm --filter @objectstack/spec check:llms-txt | |
| # 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 | |
| # before moving a step between lanes. | |
| # | |
| # Build + `turbo run typecheck` — the per-package `typecheck` scripts the | |
| # coverage gate requires. This is also the lane that SAVES the shared turbo | |
| # cache on main pushes (see that step), because it is the only lane that | |
| # runs the `typecheck` task family and so the only one that can seed it. | |
| typecheck-workspace: | |
| name: Type Check · workspace | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v7 | |
| with: | |
| # The authorable-surface deletion gate (#4650) below anchors on the | |
| # MERGE BASE of HEAD with origin/main — that is the only anchor under | |
| # which "a key this PR deleted" and "a key main gained since the fork | |
| # point" are different facts. A shallow clone has no walkable | |
| # ancestry, so `merge-base` fails and the gate falls back to | |
| # origin/main's TIP, where those two facts collapse into one and the | |
| # SECOND one is reported as the first: #6359 had PR #6356 (which | |
| # touches no spec file at all) go red for "deleting" | |
| # ui/BulkActionDef:requiredPermissions — a key main had just ADDED. | |
| # | |
| # Same line as the ESLint job above, opposite failure mode, and that | |
| # is why it is spelled out here rather than cross-referenced: shallow | |
| # degrades the slot-lookup ratchet to "not verified" (a false GREEN), | |
| # and degrades this gate to a false RED on an innocent PR. | |
| fetch-depth: 0 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: '22' | |
| - name: Setup pnpm | |
| uses: ./.github/actions/setup-pnpm | |
| - name: Get pnpm store directory | |
| shell: bash | |
| run: | | |
| echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV | |
| - name: Setup pnpm cache | |
| uses: actions/cache@v6 | |
| with: | |
| path: ${{ env.STORE_PATH }} | |
| key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} | |
| restore-keys: | | |
| ${{ runner.os }}-pnpm-store-v3- | |
| # This job runs the full workspace build below ("Build workspace | |
| # packages"); without a restored turbo cache that step rebuilt every | |
| # package from scratch on every run (~4½ min) while ci.yml's jobs — which | |
| # do carry this cache — finished the same build in under a minute. Same | |
| # key scheme as ci.yml so the fallback prefix can also hit main's caches. | |
| # Restore-only on PRs (same policy as ci.yml): PR-side saves churned the | |
| # 10 GB Actions cache pool and evicted the main seeds; only main pushes | |
| # save (the "Save Turbo cache" step at the end of the job). | |
| # | |
| # The build-core fallbacks are #5401. Only main writes, so a merge_group | |
| # entry always restores by PREFIX — and this job's own namespace has a | |
| # window where that prefix matches nothing: the previous main generation | |
| # is already gone while the current one is still being written (measured | |
| # 2026-08-05, run 30985524210 — all three keys reported "Cache not found" | |
| # at 07:34:18Z and the workspace build went 4s -> 4m54s, while the very | |
| # same queue entry's ci.yml jobs hit `-main-f417863f` in that same | |
| # minute). build-core's entry is the one that survives that window: its | |
| # main-push save had completed at 07:32:50Z, 88s earlier, and `pnpm | |
| # build` (turbo run build --filter=!@objectstack/docs) is a SUPERSET of | |
| # this job's build step, so the entries it carries are the ones this | |
| # build needs — same reasoning as the fallbacks the Temporal | |
| # Conformance / Dogfood Verify / Console Pin jobs already carry in | |
| # ci.yml, whose spelling these two lines match exactly. | |
| # | |
| # Partial by construction, and that is the honest expectation: it warms | |
| # the `build` tasks, not the `typecheck` tasks (build-core never runs | |
| # those, so nothing seeds them outside this namespace). | |
| # | |
| # ⚠️ THE NAMESPACE IS THE LITERAL `typecheck`, NOT `${{ github.job }}`. | |
| # It used to be the expression, back when one job carried this whole | |
| # family and the two spellings happened to agree. They no longer do: the | |
| # family is four parallel lanes (`typecheck-source-gates`, | |
| # `typecheck-workspace`, `typecheck-debt`, `typecheck-consumers`) plus | |
| # the `typecheck` aggregator, so `github.job` would give each lane its | |
| # OWN namespace — four prefixes nothing has ever written, and every one | |
| # of main's seeds unreachable. That failure is silent and expensive in | |
| # exactly one direction: the restore reports "Cache not found", the lane | |
| # cold-builds, and the run is still GREEN — you only see it as a job that | |
| # got slower, which is the thing this card exists to fix. The lanes share | |
| # one namespace on purpose, and it is spelled the same as the key main's | |
| # save writes below, so the seeding relationship survives the split | |
| # unchanged. | |
| - name: Restore Turbo cache | |
| uses: actions/cache/restore@v6 | |
| with: | |
| path: .turbo/cache | |
| key: ${{ runner.os }}-turbo-typecheck-${{ github.ref_name }}-${{ github.sha }} | |
| restore-keys: | | |
| ${{ runner.os }}-turbo-typecheck-${{ github.ref_name }}- | |
| ${{ runner.os }}-turbo-typecheck- | |
| ${{ runner.os }}-turbo-build-core-${{ github.ref_name }}- | |
| ${{ runner.os }}-turbo-build-core- | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| # Example apps are AI-authoring reference templates; a red typecheck is a | |
| # bad signal to copy from. tsup transpiles them without a full typecheck, | |
| # so build alone will not catch type drift — typecheck them explicitly. | |
| # They import from built workspace packages, so the packages must be built | |
| # first for cross-package type resolution to succeed. The examples' | |
| # dependency closure is requested EXPLICITLY (`./examples/*^...` = deps | |
| # of the examples, not the examples themselves): the bare `./packages/*` | |
| # glob only matches direct children, and the connector packages the | |
| # showcase imports were previously built only by accident — through | |
| # dogfood's dependency chain, which broke when dogfood moved to | |
| # packages/qa/ (#3037). | |
| - name: Build workspace packages | |
| run: pnpm exec turbo run build --filter='./packages/*' --filter='./examples/*^...' | |
| # The per-package `typecheck` scripts the coverage gate above requires | |
| # (#4311). tsc resolves workspace imports through each dependency's built | |
| # dist/*.d.ts, so the task carries `dependsOn: ^build` in turbo.json — | |
| # which also builds the handful of nested packages (packages/drivers/*, | |
| # packages/plugins/*, packages/services/*, …) the build step's | |
| # direct-children glob misses | |
| # when no example depends on them. Three filters because the bare | |
| # `./packages/*` glob only matches direct children (see the build step's | |
| # comment): the nested group dirs and apps/ (docs) need their own globs. | |
| # Examples and the downstream contract keep their dedicated steps below — | |
| # they are distinct gates, not bulk coverage. | |
| - name: Type check workspace packages | |
| run: pnpm exec turbo run typecheck --filter='./packages/*' --filter='./packages/*/*' --filter='./apps/*' | |
| # Seed the shared Turbo cache from main only (see the restore step above). | |
| # | |
| # ⚠️ EXACTLY ONE LANE SAVES, and this is it. Three of the four lanes run | |
| # the workspace build, so three could plausibly write here — but they | |
| # would all compute the SAME key (the namespace is a literal and the sha | |
| # is the run's), and `actions/cache` refuses a duplicate with "Unable to | |
| # reserve cache with key …, another job may be creating this cache". That | |
| # is a warning, not a failure, so the losers would go green having saved | |
| # nothing and the winner would be whichever lane finished first — a | |
| # non-deterministic seed. This lane is the designated writer because its | |
| # tree carries the most: it runs the workspace build AND `turbo run | |
| # typecheck`, so it is the only lane that seeds the `typecheck` task | |
| # family at all (the restore comment above explains why nothing else | |
| # does). Moving the save to another lane silently narrows what main | |
| # seeds; if that is ever wanted, move it deliberately and say so here. | |
| # | |
| # merge_group is deliberately NOT added to this condition — see the | |
| # "why queue-branch saves are not the win they look like" note on the | |
| # `typecheck` aggregator at the bottom of this file. | |
| - name: Save Turbo cache (main only) | |
| if: always() && github.event_name == 'push' | |
| uses: actions/cache/save@v6 | |
| with: | |
| path: .turbo/cache | |
| key: ${{ runner.os }}-turbo-typecheck-${{ github.ref_name }}-${{ github.sha }} | |
| # 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 | |
| # before moving a step between lanes. | |
| # | |
| # The ledger re-measure, alone in its own lane because it was ~4 min of the | |
| # old critical path. ⚠️ It is NOT duplicated work with the `turbo run | |
| # typecheck` in the lane above, which is what it looks like from the step | |
| # names: the DEBT ledger's entries are by definition the packages that have | |
| # NO `typecheck` script (measured on this tree — 0 of 13 DEBT entries | |
| # resolve to a package declaring one), so the turbo task never compiles a | |
| # single one of them, and the TEST_DEBT entries are measured against a | |
| # GENERATED project that includes the test files each package's own | |
| # tsconfig excludes — a different program from the one its `typecheck` | |
| # script runs. Two disjoint bodies of tsc work; there is nothing to dedupe, | |
| # only something to run in parallel. The ledger's shrink-only verdict | |
| # semantics are untouched: same script, same flags, same command. | |
| typecheck-debt: | |
| name: Type Check · debt ledger | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v7 | |
| with: | |
| # The authorable-surface deletion gate (#4650) below anchors on the | |
| # MERGE BASE of HEAD with origin/main — that is the only anchor under | |
| # which "a key this PR deleted" and "a key main gained since the fork | |
| # point" are different facts. A shallow clone has no walkable | |
| # ancestry, so `merge-base` fails and the gate falls back to | |
| # origin/main's TIP, where those two facts collapse into one and the | |
| # SECOND one is reported as the first: #6359 had PR #6356 (which | |
| # touches no spec file at all) go red for "deleting" | |
| # ui/BulkActionDef:requiredPermissions — a key main had just ADDED. | |
| # | |
| # Same line as the ESLint job above, opposite failure mode, and that | |
| # is why it is spelled out here rather than cross-referenced: shallow | |
| # degrades the slot-lookup ratchet to "not verified" (a false GREEN), | |
| # and degrades this gate to a false RED on an innocent PR. | |
| fetch-depth: 0 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: '22' | |
| - name: Setup pnpm | |
| uses: ./.github/actions/setup-pnpm | |
| - name: Get pnpm store directory | |
| shell: bash | |
| run: | | |
| echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV | |
| - name: Setup pnpm cache | |
| uses: actions/cache@v6 | |
| with: | |
| path: ${{ env.STORE_PATH }} | |
| key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} | |
| restore-keys: | | |
| ${{ runner.os }}-pnpm-store-v3- | |
| # This job runs the full workspace build below ("Build workspace | |
| # packages"); without a restored turbo cache that step rebuilt every | |
| # package from scratch on every run (~4½ min) while ci.yml's jobs — which | |
| # do carry this cache — finished the same build in under a minute. Same | |
| # key scheme as ci.yml so the fallback prefix can also hit main's caches. | |
| # Restore-only on PRs (same policy as ci.yml): PR-side saves churned the | |
| # 10 GB Actions cache pool and evicted the main seeds; only main pushes | |
| # save (the "Save Turbo cache" step at the end of the job). | |
| # | |
| # The build-core fallbacks are #5401. Only main writes, so a merge_group | |
| # entry always restores by PREFIX — and this job's own namespace has a | |
| # window where that prefix matches nothing: the previous main generation | |
| # is already gone while the current one is still being written (measured | |
| # 2026-08-05, run 30985524210 — all three keys reported "Cache not found" | |
| # at 07:34:18Z and the workspace build went 4s -> 4m54s, while the very | |
| # same queue entry's ci.yml jobs hit `-main-f417863f` in that same | |
| # minute). build-core's entry is the one that survives that window: its | |
| # main-push save had completed at 07:32:50Z, 88s earlier, and `pnpm | |
| # build` (turbo run build --filter=!@objectstack/docs) is a SUPERSET of | |
| # this job's build step, so the entries it carries are the ones this | |
| # build needs — same reasoning as the fallbacks the Temporal | |
| # Conformance / Dogfood Verify / Console Pin jobs already carry in | |
| # ci.yml, whose spelling these two lines match exactly. | |
| # | |
| # Partial by construction, and that is the honest expectation: it warms | |
| # the `build` tasks, not the `typecheck` tasks (build-core never runs | |
| # those, so nothing seeds them outside this namespace). | |
| # | |
| # ⚠️ THE NAMESPACE IS THE LITERAL `typecheck`, NOT `${{ github.job }}`. | |
| # It used to be the expression, back when one job carried this whole | |
| # family and the two spellings happened to agree. They no longer do: the | |
| # family is four parallel lanes (`typecheck-source-gates`, | |
| # `typecheck-workspace`, `typecheck-debt`, `typecheck-consumers`) plus | |
| # the `typecheck` aggregator, so `github.job` would give each lane its | |
| # OWN namespace — four prefixes nothing has ever written, and every one | |
| # of main's seeds unreachable. That failure is silent and expensive in | |
| # exactly one direction: the restore reports "Cache not found", the lane | |
| # cold-builds, and the run is still GREEN — you only see it as a job that | |
| # got slower, which is the thing this card exists to fix. The lanes share | |
| # one namespace on purpose, and it is spelled the same as the key main's | |
| # save writes below, so the seeding relationship survives the split | |
| # unchanged. | |
| - name: Restore Turbo cache | |
| uses: actions/cache/restore@v6 | |
| with: | |
| path: .turbo/cache | |
| key: ${{ runner.os }}-turbo-typecheck-${{ github.ref_name }}-${{ github.sha }} | |
| restore-keys: | | |
| ${{ runner.os }}-turbo-typecheck-${{ github.ref_name }}- | |
| ${{ runner.os }}-turbo-typecheck- | |
| ${{ runner.os }}-turbo-build-core-${{ github.ref_name }}- | |
| ${{ runner.os }}-turbo-build-core- | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| # Example apps are AI-authoring reference templates; a red typecheck is a | |
| # bad signal to copy from. tsup transpiles them without a full typecheck, | |
| # so build alone will not catch type drift — typecheck them explicitly. | |
| # They import from built workspace packages, so the packages must be built | |
| # first for cross-package type resolution to succeed. The examples' | |
| # dependency closure is requested EXPLICITLY (`./examples/*^...` = deps | |
| # of the examples, not the examples themselves): the bare `./packages/*` | |
| # glob only matches direct children, and the connector packages the | |
| # showcase imports were previously built only by accident — through | |
| # dogfood's dependency chain, which broke when dogfood moved to | |
| # packages/qa/ (#3037). | |
| - name: Build workspace packages | |
| run: pnpm exec turbo run build --filter='./packages/*' --filter='./examples/*^...' | |
| # The MEASURED half of the coverage gate (#5278). The cheap structural | |
| # check near the top of this job asserts that a package without a | |
| # `typecheck` script carries a DEBT/TEST_DEBT entry with a positive number | |
| # written down — and, until now, nothing more: `errors: 28` and | |
| # `errors: 1` were equally acceptable to it, because the ledger was never | |
| # re-measured. So a ledgered package's real error count could grow without | |
| # bound while the gate reported success. It had: metadata-protocol | |
| # recorded 28 and reported 63, service-analytics 3 -> 7, service-automation | |
| # 2 -> 5, and the wholesale re-measure this step ships with found 17 of the | |
| # 34 entries understated and not one overstated. A number that has drifted | |
| # 2.25x no longer describes the debt it claims to freeze. | |
| # | |
| # Asymmetric, on purpose: a count ABOVE its recorded number fails, a count | |
| # below prints an informational "can be lowered / graduation candidate" | |
| # line and stays green. Fixing errors must not also require editing a | |
| # bookkeeping number before CI will go green, or the ledger charges a toll | |
| # on exactly the work it exists to encourage. | |
| # | |
| # Here rather than beside its structural half because it runs the real | |
| # compiler over ~34 projects, and tsc resolves workspace imports through | |
| # each dependency's built `dist/*.d.ts` — so it needs the build steps | |
| # above, which this job already pays for. The build filter is widened to | |
| # the nested package groups (packages/services/*, packages/drivers/*, | |
| # packages/plugins/*, …) because most ledgered packages have no | |
| # `typecheck` script and therefore never entered the turbo task graph that | |
| # would otherwise have built their dependencies; it is a superset of what | |
| # the steps above already built, so it is cache hits plus the remainder. | |
| # Measured cost of the re-measure itself: ~4 min, sequential by design | |
| # (parallel tsc processes trade wall clock for an OOM risk on a job that | |
| # has just built the whole workspace). | |
| # | |
| # ⚠️ NOT a duplicate of the closure build the gate below runs itself | |
| # (#8331). `check:type-check-debt` calls `refreshBuiltClosure()` in | |
| # scripts/check-type-check-coverage.mjs, which runs this exact command | |
| # with these exact filters — but it runs it AFTER the `unbuiltClosure()` | |
| # refusal, never before, and that order is deliberate and documented | |
| # there: the refusal owns the NOTHING-IS-BUILT case (one ledgered | |
| # package's 7-task closure built cold measured 3m2s, on a gate people run | |
| # before pushing), the refresh owns only the PRESENT-BUT-STALE case | |
| # (#8271). So the gate cannot rescue a closure member no earlier step | |
| # built — it refuses, naming the command this step runs. | |
| # | |
| # What that leaves is measured, not assumed. Of the 55 workspace packages | |
| # in the ledgered dependency closure (the gate's own enumeration, on an | |
| # unbuilt worktree), `Build workspace packages` above already covers 54 | |
| # through `./packages/*`. The 55th is `@objectstack/service-knowledge` | |
| # (packages/services/, pulled in by the ledgered `knowledge-ragflow`), and | |
| # it arrives here only through `./examples/*^...` — because | |
| # `examples/app-todo` happens to depend on it, the same accident #3037 | |
| # records one step above. Delete this step and the lane's green starts | |
| # depending on that one manifest: drop the dependency there and | |
| # `check:type-check-debt` refuses in CI, with a remedy message naming the | |
| # command this step no longer runs. | |
| # | |
| # The drift #8331 was filed on — one precondition owned in two places — is | |
| # LOUD in both directions now that the gate refreshes with its OWN | |
| # filters: a narrower filter here ends in that refusal, a wider one in | |
| # extra cache hits. Neither can make CI measure a different world than a | |
| # local run, which is what #8271 was. Cost of keeping it: 9.5s of turbo | |
| # cache hits (70/70) against this lane's ~249s. | |
| - name: Build the ledgered packages' dependencies | |
| run: pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' | |
| - name: Re-measure the type-check DEBT / TEST_DEBT ledger | |
| run: pnpm check:type-check-debt | |
| # 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 | |
| # before moving a step between lanes. | |
| # | |
| # The post-build gates that read the BUILT `dist/` a consumer's import | |
| # actually resolves to — examples, the frozen downstream contract, the | |
| # api-surface family, the skill/docs example compilers and the i18n gates. | |
| typecheck-consumers: | |
| name: Type Check · consumer gates | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v7 | |
| with: | |
| # The authorable-surface deletion gate (#4650) below anchors on the | |
| # MERGE BASE of HEAD with origin/main — that is the only anchor under | |
| # which "a key this PR deleted" and "a key main gained since the fork | |
| # point" are different facts. A shallow clone has no walkable | |
| # ancestry, so `merge-base` fails and the gate falls back to | |
| # origin/main's TIP, where those two facts collapse into one and the | |
| # SECOND one is reported as the first: #6359 had PR #6356 (which | |
| # touches no spec file at all) go red for "deleting" | |
| # ui/BulkActionDef:requiredPermissions — a key main had just ADDED. | |
| # | |
| # Same line as the ESLint job above, opposite failure mode, and that | |
| # is why it is spelled out here rather than cross-referenced: shallow | |
| # degrades the slot-lookup ratchet to "not verified" (a false GREEN), | |
| # and degrades this gate to a false RED on an innocent PR. | |
| fetch-depth: 0 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: '22' | |
| - name: Setup pnpm | |
| uses: ./.github/actions/setup-pnpm | |
| - name: Get pnpm store directory | |
| shell: bash | |
| run: | | |
| echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV | |
| - name: Setup pnpm cache | |
| uses: actions/cache@v6 | |
| with: | |
| path: ${{ env.STORE_PATH }} | |
| key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} | |
| restore-keys: | | |
| ${{ runner.os }}-pnpm-store-v3- | |
| # This job runs the full workspace build below ("Build workspace | |
| # packages"); without a restored turbo cache that step rebuilt every | |
| # package from scratch on every run (~4½ min) while ci.yml's jobs — which | |
| # do carry this cache — finished the same build in under a minute. Same | |
| # key scheme as ci.yml so the fallback prefix can also hit main's caches. | |
| # Restore-only on PRs (same policy as ci.yml): PR-side saves churned the | |
| # 10 GB Actions cache pool and evicted the main seeds; only main pushes | |
| # save (the "Save Turbo cache" step at the end of the job). | |
| # | |
| # The build-core fallbacks are #5401. Only main writes, so a merge_group | |
| # entry always restores by PREFIX — and this job's own namespace has a | |
| # window where that prefix matches nothing: the previous main generation | |
| # is already gone while the current one is still being written (measured | |
| # 2026-08-05, run 30985524210 — all three keys reported "Cache not found" | |
| # at 07:34:18Z and the workspace build went 4s -> 4m54s, while the very | |
| # same queue entry's ci.yml jobs hit `-main-f417863f` in that same | |
| # minute). build-core's entry is the one that survives that window: its | |
| # main-push save had completed at 07:32:50Z, 88s earlier, and `pnpm | |
| # build` (turbo run build --filter=!@objectstack/docs) is a SUPERSET of | |
| # this job's build step, so the entries it carries are the ones this | |
| # build needs — same reasoning as the fallbacks the Temporal | |
| # Conformance / Dogfood Verify / Console Pin jobs already carry in | |
| # ci.yml, whose spelling these two lines match exactly. | |
| # | |
| # Partial by construction, and that is the honest expectation: it warms | |
| # the `build` tasks, not the `typecheck` tasks (build-core never runs | |
| # those, so nothing seeds them outside this namespace). | |
| # | |
| # ⚠️ THE NAMESPACE IS THE LITERAL `typecheck`, NOT `${{ github.job }}`. | |
| # It used to be the expression, back when one job carried this whole | |
| # family and the two spellings happened to agree. They no longer do: the | |
| # family is four parallel lanes (`typecheck-source-gates`, | |
| # `typecheck-workspace`, `typecheck-debt`, `typecheck-consumers`) plus | |
| # the `typecheck` aggregator, so `github.job` would give each lane its | |
| # OWN namespace — four prefixes nothing has ever written, and every one | |
| # of main's seeds unreachable. That failure is silent and expensive in | |
| # exactly one direction: the restore reports "Cache not found", the lane | |
| # cold-builds, and the run is still GREEN — you only see it as a job that | |
| # got slower, which is the thing this card exists to fix. The lanes share | |
| # one namespace on purpose, and it is spelled the same as the key main's | |
| # save writes below, so the seeding relationship survives the split | |
| # unchanged. | |
| - name: Restore Turbo cache | |
| uses: actions/cache/restore@v6 | |
| with: | |
| path: .turbo/cache | |
| key: ${{ runner.os }}-turbo-typecheck-${{ github.ref_name }}-${{ github.sha }} | |
| restore-keys: | | |
| ${{ runner.os }}-turbo-typecheck-${{ github.ref_name }}- | |
| ${{ runner.os }}-turbo-typecheck- | |
| ${{ runner.os }}-turbo-build-core-${{ github.ref_name }}- | |
| ${{ runner.os }}-turbo-build-core- | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| # Example apps are AI-authoring reference templates; a red typecheck is a | |
| # bad signal to copy from. tsup transpiles them without a full typecheck, | |
| # so build alone will not catch type drift — typecheck them explicitly. | |
| # They import from built workspace packages, so the packages must be built | |
| # first for cross-package type resolution to succeed. The examples' | |
| # dependency closure is requested EXPLICITLY (`./examples/*^...` = deps | |
| # of the examples, not the examples themselves): the bare `./packages/*` | |
| # glob only matches direct children, and the connector packages the | |
| # showcase imports were previously built only by accident — through | |
| # dogfood's dependency chain, which broke when dogfood moved to | |
| # packages/qa/ (#3037). | |
| - name: Build workspace packages | |
| run: pnpm exec turbo run build --filter='./packages/*' --filter='./examples/*^...' | |
| # The nested-package build. ⚠️ NOT redundant with "Build workspace | |
| # packages" above, and the reason is the glob: the bare `./packages/*` | |
| # matches only DIRECT children, so `packages/plugins/*`, | |
| # `packages/services/*`, `packages/drivers/*` and friends arrive only via | |
| # `./examples/*^...` — the examples' dependency closure — which reaches | |
| # most of them by accident of what the showcase happens to import. | |
| # Measured on this tree: the step above builds 68 packages, this filter | |
| # builds 74, and the 8 it adds include `@objectstack/downstream-contract` | |
| # and `@objectstack/studio`. | |
| # | |
| # In the old 45-step serial job the consumer gates below inherited this | |
| # build for free: it ran earlier in the same job, for the debt ledger's | |
| # sake, and everything after it saw the fuller tree. Splitting the ledger | |
| # into its own lane takes that inheritance away, so this lane has to ask | |
| # for it explicitly or the gates below would read a `dist/` that is | |
| # missing exactly those 8 — a build-dependent gate reading nothing and | |
| # reporting "measured and clean" (#4690). It is a superset of the step | |
| # above, so in practice it is cache hits plus the remainder (~13s in CI). | |
| - name: Build the nested packages the consumer gates resolve through | |
| run: pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' | |
| - name: Type check example apps | |
| run: pnpm --filter './examples/*' run typecheck | |
| # Backward-compatibility gate: a frozen third-party-style consumer | |
| # (#2035). Unlike the examples it must NOT be migrated to accommodate a | |
| # 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 | |
| run: pnpm --filter @objectstack/downstream-contract run typecheck | |
| # Public API-surface gate (#2035): the spec package IS the third-party API. | |
| # A removed/renamed export silently breaks every consumer pinned to a | |
| # published release. This diffs the built export surface against the | |
| # committed snapshot; intentional changes regenerate it via | |
| # `pnpm --filter @objectstack/spec gen:api-surface`. Runs after the build | |
| # step above (reads the built dist). | |
| # Published-README export gate (#9532, from #9517). Six published service | |
| # READMEs documented a `.configure()` entry point on classes that exist | |
| # nowhere in the repo — `PluginAudit`, `ServiceAnalytics`, | |
| # `ServiceAutomation`, `ServiceCache`, `ServiceI18n`, `ServiceJob`. A | |
| # reader following any of them wrote code that could not compile, and | |
| # because each README is in its package's `files` array with `private` | |
| # unset, those are the pages npm renders. Six instances of one defect is a | |
| # missing gate, not six mistakes: nothing read a published README against | |
| # its package's exports, so drift was free in both directions. | |
| # | |
| # WHY THIS JOB AND NOT THE LINT JOB. It resolves each documented symbol | |
| # through the `exports` map to the BUILT `.d.ts` — the surface a consumer | |
| # actually installs, and the only one that can show a subpath the README | |
| # imports but `exports` never declares. That makes it build-dependent, so | |
| # it sits after the two build steps above with the other dist-reading | |
| # gate (`check:api-surface`, right below). A missing type entry is a HARD | |
| # ERROR naming the build command, never a skip — a build-dependent gate | |
| # that silently reads nothing reports "not measured" as if it were | |
| # "measured and clean" (#4690). | |
| # | |
| # It adds no required context: this is a step in an existing job, so no | |
| # open PR waits on a check whose name no head has ever reported (#9325). | |
| # | |
| # Scope was set by measurement, not taste — the card warned a naive | |
| # version "will drown in false positives on prose and pseudo-code", and a | |
| # muted gate is worse than none because it still reads as coverage. Of the | |
| # 145 lines across 50 published READMEs carrying both `import` and an | |
| # `@objectstack/` specifier, the 3 outside code fences are ALL prose false | |
| # positives; `diff` fences read their `+` side only; CHANGELOG.md is out | |
| # of scope because a changelog documents the past. Runs its own | |
| # --self-test first, which drives the whole pipeline offline in both | |
| # 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 | |
| run: pnpm check:published-readme-exports | |
| - name: Check @objectstack/spec public API surface | |
| run: pnpm --filter @objectstack/spec run check:api-surface | |
| # [#11350] Consumer-shaped declaration-emit pin against the BUILT root | |
| # entry (an un-annotated `export default defineStack(...)` must compile | |
| # with `declaration: true` — the TS2883 class). The pin is environment- | |
| # gated the way the live-dialect cells are: under Test Core, spec's own | |
| # dist is deliberately never built (turbo's `test` depends on `^build`, | |
| # dependencies only), so there it declares a named skip. THIS lane builds | |
| # the full packages closure above, so here the built dist is guaranteed — | |
| # the flag turns "dist absent/stale" from a skip into a failure, which is | |
| # what stops the pin from quietly degrading to never-measured if the | |
| # build steps above are ever dropped (#4690). Sits with its family: | |
| # `check:api-surface` / `check:skill-examples`, the other gates that read | |
| # 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) | |
| env: | |
| OS_EXPECT_ROOT_NAMEABILITY: '1' | |
| run: pnpm --filter @objectstack/spec exec vitest run scripts/root-entry-type-nameability.pin.test.ts | |
| # [#11986] The same invariant, generalized from ONE entry to all 17 — the | |
| # C half of the 2026-08-25 ruling on #11709 (A′ was the two re-exports, | |
| # this is the gate that stops a fourth round of per-name repair). The pin | |
| # above keeps its own charter: it reproduces the REAL configs' program | |
| # shape verbatim, fixture file included. This one compiles a probe per | |
| # public entry over every callable export, so no entry sits unmeasured. | |
| # | |
| # ⛔ Never batch the entries into one program to save time. A program file | |
| # importing a subpath entry makes that entry's names nameable PROGRAM-WIDE | |
| # (#11350's control; the gate's --self-test measures it in both | |
| # directions), so a single batched program reports zero leaks no matter how | |
| # many exist — full cost, no measurement. Measured here: 47s for all 17 | |
| # entries, 422 probes. | |
| # | |
| # Sits with its family — `check:api-surface` / `check:exported-any` / | |
| # `check:dual-source-exports` — because it reads the built dist a | |
| # consumer's import actually resolves to, so it needs the build steps | |
| # 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) | |
| run: pnpm --filter @objectstack/spec run check:entry-nameability | |
| # Same surface, the other axis: api-surface/ records that an export | |
| # EXISTS, never what it resolves to — so four exported types sat at `any` | |
| # across a whole major with every gate green (#4171). #4115 tells consumers | |
| # to replace a local declaration with the spec import, which for those four | |
| # traded a precise type for one that constrains nothing, silently: `any` is | |
| # mutually assignable with everything, so the check that would catch the | |
| # swap reports "identical, safe to re-export". Reads the built dist a | |
| # consumer's import actually resolves to, so it runs after the build step | |
| # 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` | |
| run: pnpm --filter @objectstack/spec run check:exported-any | |
| # [#11927] The same question, asked of the SDK boundary instead of the | |
| # spec surface — and asked because #8140 bound 51 erased return types | |
| # across `packages/client` by hand, which buys a snapshot and not a | |
| # property. Nothing stopped the 52nd. | |
| # | |
| # It reads the built `.d.ts` for the reason the neighbour above does, plus | |
| # one this package makes sharper: the largest erasure class here carries NO | |
| # return annotation at all, taking its type from `unwrapResponse<any>` | |
| # (#11925) or from a bare `res.json()`, which lib.dom declares | |
| # `Promise<any>` (#12104). Neither spelling contains the text `Promise` or | |
| # `any`, so no grep can see them — only a checker asking what the export | |
| # RESOLVES to. So it belongs here with the other dist-reading consumer | |
| # gates, after the build steps, and on an unbuilt or stale tree it is a | |
| # hard refusal naming the build command rather than a skip (#4690, #7122). | |
| # | |
| # Baseline, not zero: 65 sites are carried as NAMED, shrink-only ledger | |
| # entries with a written reason each (packages/client/exported-any-returns.json), | |
| # because four of them keep `any` deliberately — no contract exists to bind | |
| # (#11924) — and a gate demanding zero would either block on that work or | |
| # invite a false declaration to reach green. Self-tests first, like its | |
| # neighbours, and that self-test is load-bearing in a specific direction | |
| # here: a caller-supplied `<T = any>` is NOT erasure, and a detector that | |
| # cannot tell the two apart produces pressure to replace a correct generic | |
| # with a wrong concrete type. Both directions are pinned there. | |
| # | |
| # Adds no required context — a step in an existing lane (#9325). | |
| - name: Check no exported client callable resolves to `any` | |
| run: pnpm --filter @objectstack/client run check:exported-any-returns | |
| # Third axis on the same surface: api-surface/ shows a name on two | |
| # entries but not whether the two are ONE declaration re-exported (fine) | |
| # or TWO declarations sharing a name — the #4411 trap, where which type a | |
| # consumer gets depends on nothing but the import path and the copy that | |
| # LOOKS canonical can be the dead one. Judged by symbol identity against | |
| # the built dist; existing dual-sources live in a shrink-only baseline | |
| # (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 | |
| run: pnpm --filter @objectstack/spec run check:dual-source-exports | |
| # [#10199] The mechanized form of the 2026-08-20 ruling on #10096: | |
| # 「浏览器可达的 spec 导出面必须 schema-free」. Walks the built module graph | |
| # behind every subpath the exports map publishes and fails if one DECLARED | |
| # browser-reachable (browser-reachable-entries.json — today `./meta-spelling`) | |
| # links zod, directly or through a relative hop, or links an external it has | |
| # not declared. | |
| # | |
| # WHY IT NEEDS A GATE AT ALL. The principle bound as prose until now, and | |
| # the failure it guards was invisible to every check this repo had: one | |
| # string fold reached through `/shared` cost +246.9 KB minified / +69.7 KB | |
| # gzipped, one pure predicate through `/security` cost +261.5 KB, and | |
| # objectui#5324 records the CONSUMER's own bundle-budget check reporting | |
| # PASS on the PR that added the bytes. A budget cannot attribute a delta | |
| # that arrives inside a dependency it already imports; a module graph can. | |
| # | |
| # WHY THIS JOB AND THIS POSITION. It reads the BUILT `.mjs`/`.js` bundles a | |
| # consumer's import resolves to — not the source, and not the `.d.ts` — so | |
| # it belongs after the build step with the other dist-reading consumer | |
| # gates (`check:exported-any`, `check:dual-source-exports` above), not with | |
| # the tsx-only artifact gates near the top. On an unbuilt or stale tree it | |
| # is a HARD REFUSAL naming the build command, never a skip: a | |
| # build-dependent gate that reads nothing reports NOT MEASURED as if it | |
| # were measured and clean (#4690). | |
| # | |
| # It adds no required context — a step in an existing job, so no open PR | |
| # waits on a check whose name no head has ever reported (#9325). Reads 34 | |
| # bundles in ~1.5s. Self-tests first, like its two neighbours, and that | |
| # self-test is load-bearing here for a specific reason: this tree has ZERO | |
| # relative hops between bundles (every entry is self-contained), so the | |
| # 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 | |
| run: pnpm --filter @objectstack/spec run check:browser-reachable-entries | |
| # Anti-drift for the skill EXAMPLES, not just the skill reference indexes | |
| # (#3094). The TypeScript in skills/ is the first thing an AI copies when | |
| # authoring metadata, yet nothing type-checked it — so it rotted silently | |
| # (`ObjectSchema`/`Data`/`Field` imported from the wrong entry point, a | |
| # `defineStack` key that no longer exists). Each block tagged with an | |
| # `<!-- os:check -->` comment is extracted and run through `tsc --noEmit` | |
| # against the built `@objectstack/spec` declarations — the exact surface a | |
| # consumer's import resolves to — so a renamed export or tightened union | |
| # 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 | |
| run: pnpm --filter @objectstack/spec run check:skill-examples | |
| # The SEMANTIC half of the same surface (#5116). The gate above proves a | |
| # formula example COMPILES; it cannot prove the CEL is right, because | |
| # `expression` is typed `string` — `'quantity * price'` type-checks exactly | |
| # as well as `'record.quantity * record.price'`, and only the second one | |
| # works. A bare reference does not throw at runtime either: it resolves to | |
| # nothing and the expression silently evaluates to null. That is how two doc | |
| # examples stayed wrong until #5026's activation sweep found them — one of | |
| # them in content/blog/, which check:skill-examples does not scan at all. | |
| # | |
| # The verdict is `validateExpression` imported from @objectstack/formula — | |
| # the same call `os build` / `os validate` makes — never a lookalike, so the | |
| # docs are gated by the rule rather than by a dialect of it. Hence its home | |
| # in packages/lint (which owns that verdict) and its place in this | |
| # post-build job: it reads the built formula package like its neighbours. | |
| # | |
| # Deliberately narrow: a site is opted in only by parsed structure that can | |
| # mean nothing else (`Field.*({ expression })`, or `type: 'formula'` beside | |
| # `expression`), because the corpus spells three unrelated contracts with | |
| # the one key `expression:` — a cron string, and a flow-scoped predicate | |
| # where a bare reference is CORRECT, are the other two. Self-test first, | |
| # both directions. | |
| - name: Check docs formula examples are valid CEL | |
| run: pnpm --filter @objectstack/lint run check:doc-formula-expressions | |
| # The AUTHOR-TIME-LINT half of the same surface (#10618). check:skill-examples | |
| # proves an `os:check` block COMPILES; it cannot prove `os validate` accepts | |
| # what the block constructs, because the lint rules judge VALUES, not types: | |
| # `sharingModel` is `.optional()` with no `.default()`, so omitting it | |
| # type-checks clean while `os validate` rejects the object as severity: | |
| # 'error' (SECURITY_OWD_UNSET — the ADR-0090 D1 authored-baseline rule). | |
| # That is how 22 canonical docs examples across 12 pages shipped a shape | |
| # the platform's own gate rejects (#10581; fixed by the #10714 sweep, plus | |
| # 8 more this gate's first run found on pages outside that sweep's scope). | |
| # | |
| # The verdict is `validateSecurityPosture` imported from @objectstack/lint — | |
| # the same gating-tier registry entry `os validate` / `os compile` and the | |
| # runtime publish gate run — never a lookalike, so the docs are gated by | |
| # the rule rather than a dialect of it. Same home and reasoning as its | |
| # CEL neighbour directly above. The constructed `ObjectSchema.create` | |
| # literal is STATICALLY evaluated (never executed), with per-fact | |
| # conservatism: a verdict is only trusted where its facts are statically | |
| # known, so `Field.*` factory calls and dynamic values can never turn | |
| # into a false red. Self-test first, both directions. | |
| - name: Check docs object examples pass the os validate security posture | |
| run: pnpm --filter @objectstack/lint run check:doc-security-posture | |
| # Same anti-drift class as the gates above, for the generated translation | |
| # bundles in packages/platform-objects/src/apps/translations/. Nothing | |
| # regenerated them either, so they rotted three ways at once (#3670): | |
| # translations left behind for keys the schema had REMOVED (`enable.trash` | |
| # / `enable.mru`, #2377; agent `visibility`, #1901), keys the schema had | |
| # GAINED with no entry at all (`summaryOperations.*`, the ADR-0105 D8 | |
| # invitation-placement fields, and the better-auth 1.7 columns from | |
| # #3647), and `sys_migration` sitting on EMPTY STRINGS in ja-JP/es-ES — | |
| # which renders blank rather than falling back to anything readable. | |
| # | |
| # Every one of those was found by a human happening to re-run the | |
| # extractor. This turns the next one into a red build. Runs in merge | |
| # mode, so it never asks anyone to re-translate: a fresh extract of an | |
| # up-to-date bundle is byte-identical to what is committed. | |
| # | |
| # Now covers ALL NINE packages that own a bundle, not just | |
| # platform-objects. The other eight shipped an `i18n-extract.config.ts` | |
| # that nothing ever ran, and four of them had already drifted — the same | |
| # rot this gate exists to catch, one directory over. Each package is | |
| # checked with the exact command its own config docstring documents, so | |
| # the docs and the gate cannot diverge. | |
| # | |
| # 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 | |
| run: pnpm check:i18n | |
| # Ratchet on the OTHER i18n question. The step above asks "are the | |
| # generated bundles still what the schema produces?"; this one asks "did | |
| # anyone declare a new label and not translate it?" — the gap #3370 closed | |
| # in `os lint`, which had ~1000 pre-existing misses across the examples and | |
| # platform-objects, so `--i18n-strict` cannot simply be switched on without | |
| # painting CI red and getting switched back off. The debt is frozen in | |
| # scripts/i18n-coverage-baseline.json; growth fails the build. | |
| # | |
| # Eight of the twelve tracked configs sit at zero, so for those this is | |
| # already the strict gate — any regression is immediately red. | |
| # | |
| # Runs the built CLI over each config, so it also belongs after the build. | |
| - name: Check no new untranslated declared labels | |
| run: pnpm check:i18n-coverage | |
| # The THIRD i18n question, and the one neither step above can answer | |
| # (#5750). Both of them read STATIC declarations; the Setup app declares a | |
| # shell of empty group anchors and gets its ~50 menu entries at RUNTIME | |
| # from SETUP_NAV_CONTRIBUTIONS and from the capability plugins that own the | |
| # underlying objects (ADR-0029 D7). So those labels were covered by | |
| # nothing: the extract config deferred them to the ratchet, the parity test | |
| # deferred them to the ratchet, and the ratchet walks static configs. Four | |
| # of them were untranslated in `zh-CN` while every gate reported green. | |
| # | |
| # This one boots the real composition, merges the contributions the same | |
| # way the `/api/v1/meta/app` read path does, and asserts every merged nav | |
| # id carries a label in every locale the platform bundle declares. | |
| # | |
| # 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 | |
| run: pnpm check:app-nav-i18n | |
| # ──────────────────────────────────────────────────────────────────────── | |
| # The required context `TypeScript Type Check`, as an AGGREGATOR (#10471). | |
| # ──────────────────────────────────────────────────────────────────────── | |
| # | |
| # ⚠️ `typecheck` (the job id) and `TypeScript Type Check` (the `name:`) are | |
| # BOTH contract, pinned by `scripts/check-required-contexts.mjs`: the id is | |
| # what its registry looks up, the name is what the merge queue's ruleset | |
| # requires. Renaming either half is the #5617 outage — the old context never | |
| # reports again and sits permanently pending, wedging every open PR and the | |
| # queue; removing it from Settings to unwedge things drops the whole gate | |
| # family to advisory with no signal anywhere. That is why this split is an | |
| # aggregator and not a rename: the 45 steps moved into the four lanes below, | |
| # the CONTEXT NAME did not move at all, and no ruleset edit is needed (which | |
| # matters — a ruleset edit is an admin action, not something a PR can carry). | |
| # | |
| # WHY THE SPLIT. This job was 45 steps run strictly serially, and it was the | |
| # merge queue's global long pole: every candidate waits for it, so its wall | |
| # clock IS the queue's per-candidate cost. Measured on real queue builds | |
| # 2026-08-20 23:12–23:30Z (runs 32427686961 / 32428033244 / 32428961050): | |
| # 17.9 / 17.8 / 17.1 min, against ~6 min for its sibling `Lint & Repo Gates`. | |
| # Step timings from run 32428033244 (job 96613907655, 17m45s wall): | |
| # | |
| # 41s checkout + setup + install | |
| # 78s the source-only gates -> lane `typecheck-source-gates` | |
| # 332s Build workspace packages -> lanes workspace / debt / consumers | |
| # 226s Type check workspace -> lane `typecheck-workspace` | |
| # 249s build ledgered + re-measure-> lane `typecheck-debt` | |
| # 123s the post-build gates -> lane `typecheck-consumers` | |
| # | |
| # The four lanes are independent — no lane reads an artifact another lane | |
| # writes — so the critical path becomes the LONGEST lane rather than the sum, | |
| # and the build is paid three times in parallel instead of once in series. | |
| # That trade is deliberate: runner-minutes are cheap and horizontally | |
| # scalable, queue wall clock is neither (it is serialized across every | |
| # candidate). Predicted ~10.5 min; the real number is this PR's own CI run. | |
| # | |
| # ⛔ NO GATE MOVED, WEAKENED OR CHANGED ITS COMMAND. Every step below is the | |
| # same `run:` it was, in the same relative order, with the same comment. The | |
| # two order-dependent pairs stayed together inside ONE lane rather than being | |
| # split across lanes that cannot see each other's disks: | |
| # - `check:authorable-surface` must stay ABOVE `check:docs` (it writes the | |
| # gitignored json-schema/ tree that check:docs renders from) — both are | |
| # in `typecheck-source-gates`, still adjacent, still in that order; | |
| # - `Build the ledgered packages' dependencies` must stay immediately above | |
| # the re-measure — both are in `typecheck-debt`. | |
| # | |
| # WHY QUEUE-BRANCH CACHE SAVES ARE NOT THE WIN THEY LOOK LIKE (the third | |
| # direction on #10471's card, recorded here because the next reader will have | |
| # the same idea). The proposal was to extend the turbo cache save to | |
| # `merge_group` so successive queue candidates chain-hit. They cannot: | |
| # Actions scopes a cache to the ref that created it, and a run may read its | |
| # OWN ref plus the default branch (plus a PR's base) — never a sibling. Every | |
| # queue candidate gets a fresh, ephemeral `gh-readonly-queue/main/pr-N-<sha>` | |
| # branch, so candidate N+1 is a sibling of candidate N and cannot read what it | |
| # wrote. The saves would be write-only. Worse, they would not be free: this | |
| # repo has already measured what non-main saves cost — the restore comment in | |
| # each lane records that "PR-side saves churned the 10 GB Actions cache pool | |
| # and evicted the main seeds", which is the one thing that DOES make queue | |
| # builds cold. So queue-branch saves buy nothing and actively damage the seed | |
| # that main writes. Not done, on purpose. What the cache direction DID yield | |
| # is the namespace pin in the restore step — see its comment. | |
| typecheck: | |
| name: TypeScript Type Check | |
| needs: | |
| - typecheck-source-gates | |
| - typecheck-workspace | |
| - typecheck-debt | |
| - typecheck-consumers | |
| # ── ROSTER DECLARATION (#10490) ─────────────────────────────────────────── | |
| # The lanes this required aggregate stands in for, named rather than | |
| # counted. It replaces the hand-maintained `EXPECTED_LANES = 4` that used to | |
| # live in the verdict below: a number covers one aggregate, goes stale the | |
| # moment a lane moves, and cannot say WHICH lane went missing. | |
| # | |
| # Two things read this list, which is why it is real YAML and not a comment: | |
| # - `node scripts/check-aggregator-roster.mjs` (statically, in the lint | |
| # job) asserts it equals this job's `needs:` EXACTLY, in both | |
| # directions. A fifth lane added to this workflow and forgotten here | |
| # would otherwise ride green as an ADVISORY check-run behind the | |
| # required "TypeScript Type Check" context — a job that appears in the | |
| # checks list, appears required, and blocks nothing; | |
| # - the verdict step below (at run time) checks the same equality against | |
| # `toJSON(needs)`, so a drift is red in CI even before the lint job | |
| # reaches it. | |
| # This aggregate has no non-member inputs: every job it lists in `needs:` is | |
| # a lane whose verdict it carries. | |
| env: | |
| OS_AGGREGATOR_MEMBERS: typecheck-source-gates typecheck-workspace typecheck-debt typecheck-consumers | |
| # `always()` is load-bearing and must not become `success()` or the implicit | |
| # bare condition: with those, a failed lane SKIPS this job, and a skipped | |
| # required context is not a red — it is an absence, which reads as green in | |
| # every checks list and never blocks the queue. The same contract | |
| # ci.yml's `test-gate` documents. With `always()` the job runs on every | |
| # outcome and the verdict below is what decides. | |
| if: always() | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| steps: | |
| # Fail-closed, and deliberately NOT a hand-written list of the four lane | |
| # names. The verdict iterates `toJSON(needs)`, so every job wired into | |
| # `needs:` above is enforced automatically and none can be forgotten in | |
| # the logic here. The other half of that drift — a fifth lane added to the | |
| # workflow but never wired into `needs:`, which would ride green and | |
| # unwatched behind a required context — is caught by comparing `needs:` | |
| # against the job's declared ROSTER (#10490). That replaces the hand- | |
| # maintained `EXPECTED_LANES` count this step used to carry: a count goes | |
| # stale the moment a lane moves and cannot say which lane went missing. | |
| # Anything that is not exactly `success` fails, `skipped` and `cancelled` | |
| # included: a lane that never ran has not proved anything. | |
| - name: Verify every type-check lane succeeded | |
| env: | |
| OS_NEEDS: ${{ toJSON(needs) }} | |
| run: | | |
| set -euo pipefail | |
| printf '%s' "$OS_NEEDS" > "$RUNNER_TEMP/needs.json" | |
| cat > "$RUNNER_TEMP/verify-lanes.mjs" <<'NODE' | |
| import { readFileSync } from 'node:fs'; | |
| // The roster this aggregator declares, read from the job-level | |
| // `OS_AGGREGATOR_MEMBERS` above rather than re-typed here -- one | |
| // declaration, two readers (this step, and the static | |
| // scripts/check-aggregator-roster.mjs in the lint job). | |
| const declared = (process.env.OS_AGGREGATOR_MEMBERS ?? '').trim().split(/\s+/).filter(Boolean); | |
| const declaredSet = new Set(declared); | |
| const needs = JSON.parse(readFileSync(`${process.env.RUNNER_TEMP}/needs.json`, 'utf8')); | |
| const ids = Object.keys(needs).sort(); | |
| let bad = 0; | |
| // An empty declaration would make every comparison below trivially | |
| // true. Refuse rather than report a green over an unread roster. | |
| if (declared.length === 0) { | |
| console.log( | |
| '::error::this job declares no OS_AGGREGATOR_MEMBERS roster, so there is nothing to hold `needs:` to -- ' + | |
| 'refusing to report a pass (#4690).', | |
| ); | |
| bad++; | |
| } | |
| for (const id of ids) { | |
| const result = needs[id]?.result ?? '(no result reported)'; | |
| if (result === 'success') { | |
| console.log(` ok ${id}`); | |
| continue; | |
| } | |
| console.log(`::error::type-check lane \`${id}\` concluded \`${result}\` -- expected \`success\`.`); | |
| bad++; | |
| } | |
| for (const id of declared.filter((d) => !ids.includes(d))) { | |
| console.log( | |
| `::error::declared lane \`${id}\` is missing from this job's \`needs:\`, so its verdict is NOT aggregated -- ` + | |
| `it publishes an advisory check-run and rides green behind a required context.`, | |
| ); | |
| bad++; | |
| } | |
| for (const id of ids.filter((i) => !declaredSet.has(i))) { | |
| console.log( | |
| `::error::job \`${id}\` is in this job's \`needs:\` but not in the declared roster -- add it to ` + | |
| `OS_AGGREGATOR_MEMBERS, or drop it from \`needs:\`.`, | |
| ); | |
| bad++; | |
| } | |
| if (bad > 0) { | |
| console.log(`check-typecheck-lanes: FAILED (${bad} problem(s) above).`); | |
| process.exit(1); | |
| } | |
| console.log(`check-typecheck-lanes: OK (${ids.length} declared lane(s) succeeded: ${ids.join(', ')}).`); | |
| NODE | |
| node "$RUNNER_TEMP/verify-lanes.mjs" |