diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fff3c28fea..0cd415e4e96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,12 +111,12 @@ concurrency: # A `pull_request` or `merge_group` run carries the FAST SET: `Build Core`, # `Test Core` (affected-only) and `Build Docs`. `main`'s hourly `schedule` run, # its `push` runs and an on-demand `workflow_dispatch` carry the FULL BATTERY: -# the fast set plus the four jobs below that spell +# the fast set plus the three jobs below that spell # # github.event_name != 'pull_request' && github.event_name != 'merge_group' # -# in their `if:` — `temporal-conformance`, `dogfood` (three shards), -# `dogfood-verify` and `console-pin`. Nothing is deleted: the jobs moved off the +# in their `if:` — `temporal-conformance`, `dogfood` (three shards) and +# `dogfood-verify`. Nothing is deleted: the jobs moved off the # per-PR path. A red in one of them is a main-red for the owning lane, and the # publish lane reads the full battery before its approval (release.yml's # `full-battery` job, `scripts/ci/scheduled-full-run.mjs --release-gate`). @@ -146,7 +146,6 @@ jobs: # supplies their value without touching PR/push behavior. docs: ${{ steps.changes.outputs.docs || 'true' }} core: ${{ steps.changes.outputs.core || 'true' }} - console: ${{ steps.changes.outputs.console || 'true' }} # Gates the `test` job ONLY, ORed with `core` — see the `crosspkg` # filter below and the `test` job's `if:` (#9829, #10015). crosspkg: ${{ steps.changes.outputs.crosspkg || 'true' }} @@ -305,8 +304,7 @@ jobs: # them, so before these rows a diff confined to one started no # Build Core and the changed guard ran nowhere before the merge # queue (PR #17100 changed only check-lean-entry-closure.mjs; - # Build Core skipped). A change to the guard runs the guard, as - # the `console:` filter below already does for its own. + # Build Core skipped). A change to the guard runs the guard. # # Not a hand-kept list: scripts/check-ci-filter-parity.mjs # DERIVES the set from the job's steps and reds while a script a @@ -328,56 +326,6 @@ jobs: - 'scripts/ci/assert-turbo-remote-cache.mjs' - 'scripts/ci/prune-turbo-cache.mjs' - 'scripts/population-floor.mjs' - # Build inputs of the vendored Console SPA — see the Console Pin Gate - # job at the bottom of this file. `.objectui-sha` is a ROOT DOTFILE, so - # it matches neither filter above: a pin-only diff skipped `core` and - # `docs` alike, which is how #4288 moved the pin 76 commits with six of - # fourteen checks skipped and nothing anywhere building the new SHA. - # - # Every entry is one of two kinds, and scripts/check-ci-filter-parity.mjs - # holds each one to its kind (#20765): - # - a BUILD INPUT: something the console build reads to produce the - # cached dist or the injection stamp inside it. Each one is ALSO - # hashed into the `console-pin` job's dist-cache key. A head that - # selects the job by moving a build input and then restores the old - # dist from cache runs the gate without judging the change, because - # the build-time assertion lives inside the build step a hit skips. - # - a GUARD: code every run executes, cache hit or miss (the two check - # scripts, this workflow). That is the "a change to the guard runs the - # guard" rule the repo applies to every other filtered gate, and it is - # close to free here because an unmoved key hits the dist cache. - # The two packages/spec entries are the spec's ENTRY LAYOUT: the exports - # map that objectui's OBJECTSTACK_SPEC_DIST hook and the probe derivation - # both resolve entry by entry, and the tsup entry list that decides which - # built file each entry points at. A head that moves them runs this gate - # on the push that lands it (the job is full-battery only since #22998; - # see THE TWO SPEEDS above). #20695 moved the migration registries to a - # new entry, skipped this job on every push, and went red first in the - # merge queue, where a check outside the required set cannot stop a merge. - # ⛔ Not packages/spec/**: the rest of the spec is bundled CONTENT, and - # its lag behind a cached dist is the ruled cache design (#9667, #9706; - # see scripts/check-console-injection.mjs). - # The six other manifests are what build-console.sh packs into objectui's - # install on a pin carrying .objectstack-sha; objectui's objectstack-pin.mjs - # refuses one whose dependency names its lockfile does not record. All seven - # are changesets fixed group 0: of the 2727 first-parent commits on main in the - # 30 days to ec951e29e, 11 moved one of the six, 10 with spec's: 54 key moves -> 55. - console: - - '.objectui-sha' - - 'scripts/build-console.sh' - - 'scripts/check-console-sha.mjs' - - 'scripts/check-console-injection.mjs' - - 'scripts/console-spec-probes.mjs' - - 'scripts/assert-console-spec-injection.mjs' - - 'packages/spec/package.json' - - 'packages/spec/tsup.config.ts' - - 'packages/core/package.json' - - 'packages/types/package.json' - - 'packages/client/package.json' - - 'packages/formula/package.json' - - 'packages/sdui-parser/package.json' - - 'packages/lint/package.json' - - '.github/workflows/ci.yml' # Test inputs that live OUTSIDE every package (#9829, #10015). Packages # whose suites read across their own boundary declare that radius in # scripts/check-cross-package-test-inputs.mjs, and turbo.json mirrors each @@ -477,8 +425,8 @@ jobs: - '.github/workflows/ci.yml' # The seven packages objectui installs from this tree (the set its # `.objectstack-pin` used to pack). A change here compiles and builds - # the Console in the same PR, which is the check Spec Shape Gate runs - # hourly against a clone of objectui main until the import retires it. + # the Console in the same PR — the check the retired Spec Shape Gate + # ran hourly against a clone of objectui main. ui_consumers: - 'packages/spec/**' - 'packages/client/**' @@ -2487,7 +2435,7 @@ jobs: restore-keys: | ${{ runner.os }}-pnpm-store-v3- - # Restore-only, with no save step at all (the console-pin pattern): the + # Restore-only, with no save step at all: the # job's only build is the CLI closure, and the build-core fallbacks are # the entries that actually hit — that job builds a superset and is # seeded from main. @@ -3084,232 +3032,6 @@ jobs: fi echo "Built docs app: BUILD_ID=$(cat apps/docs/.next/BUILD_ID)" - # ── The pinned objectui SHA actually builds (#4290) ─────────────────────── - # - # `.objectui-sha` is the single source of truth for the vendored Console SPA: - # release.yml reads it, clones objectstack-ai/objectui at that commit, builds - # @object-ui/console, and copies the dist into packages/console/. Editing that - # one line replaces the entire frontend the platform ships. - # - # Nothing on the PR side ever built it. The pin appeared in exactly two - # workflows, neither of them a gate: release.yml (post-merge, on the main push) - # and showcase-smoke.yml (manual + nightly, whose own header says "it never - # gates PRs"). Combined with the filter hole noted above, #4288 merged a - # 76-commit pin bump green with six of fourteen checks skipped. A SHA that - # cannot build — a typo, a commit that was force-pushed away, a genuinely - # broken objectui, a dead bundle canary — reached main unchallenged and would - # have surfaced at PUBLISH time. The only thing standing between the two was - # the author remembering to run scripts/build-console.sh locally: discipline, - # not a gate. - # - # `pnpm objectui:refresh` does include the build, so the normal path was - # already covered in practice; the hole is a hand-edited pin, or that single - # line cherry-picked out of another branch. - # - # Cost is real but narrowly aimed. A moved BUILD INPUT (the pin, the build and - # probe scripts, the spec's entry layout; see the `console` filter's comment) - # is the only trigger that always pays the full clone + vite build: 32 of the - # 3219 first-parent commits on main from 2026-08-31 to 9905e61ca2 moved one. - # The other triggers (this file, the check scripts) leave the key untouched, - # so they cost about a minute whenever the entry is still in the cache. - # - # Scope, stated plainly: this proves the PINNED SHA builds. It does NOT cover a - # packages/client change breaking the injected-client bundle — that input lives - # under `packages/**`, and watching it here would rebuild the console on a large - # fraction of every PR. release.yml stays the only check for that direction. - # - # ⚠️ This gate is ADVISORY, and its name is no longer pinned. It was enrolled - # in `check:required-contexts` in #6865 on the strength of the 2026-08-09 - # ruling approving it for the required set; the live ruleset read of - # 2026-08-18 (#9533) found it had never landed there, and the row was dropped - # rather than left claiming otherwise — see that file's TOMBSTONE for the full - # provenance. So a red here does NOT block a merge, and renaming this job no - # longer turns anything red. The name is still load-bearing PROSE that renaming - # this job silently falsifies — docs/releases-maintenance.md and - # packages/console/README.md both describe the pin flow by this name (#9793 - # tracks the missing pin for job names that are not required contexts). - # - # ⚠️ It used to need telling apart from a same-shaped sibling, `Console Pin - # Freshness`, which asked whether the pin was CURRENT. That gate is gone - # (#10134, 2026-08-20 ruling: which objectui revision we pin is a decision - # recorded in an objectstack issue, never derived from objectui `main`). This - # gate's question is unaffected and it stays: does the PINNED SHA still BUILD. - # ⛔ Do not read the sibling's deletion as licence to rebuild one under this - # name — see scripts/check-required-contexts.mjs for why that name in - # particular must stay out of any required set. - console-pin: - name: Console Pin Gate - needs: filter - # See THE FILTER CONTRACT on the filter job's outputs (#4928). `console`, - # not `core` — each job keeps its own filter output. Full battery only: see - # THE TWO SPEEDS above `jobs:` (#22998). - if: ${{ !cancelled() && needs.filter.outputs.console != 'false' && github.event_name != 'pull_request' && github.event_name != 'merge_group' }} - runs-on: ubuntu-latest - timeout-minutes: 45 - permissions: - contents: read - - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - 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- - - # Restore-only, and with no save step at all: this job runs a handful of - # times a month, so its own namespace would almost always be cold. The - # build-core fallbacks below are the ones that actually hit — that job - # builds a superset of the client closure needed here and is seeded from - # main (same reasoning as the Temporal Conformance job's fallback). - - name: Restore Turbo cache - uses: actions/cache/restore@v6 - with: - path: .turbo/cache - key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}- - ${{ runner.os }}-turbo-${{ github.job }}- - ${{ runner.os }}-turbo-build-core-${{ github.ref_name }}- - ${{ runner.os }}-turbo-build-core- - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - # build-console.sh bundles THIS tree's @objectstack/client into the console - # (via OBJECTSTACK_CLIENT_DIST) and falls back to building it in place when - # the dist is missing — but that fallback is a bare `pnpm build` inside - # packages/client, which tsup cannot finish unless @objectstack/core and - # @objectstack/spec are built already. Build the closure through turbo - # instead: cacheable, and near-free off the restore above. Both of the - # script's existing callers (release.yml, showcase-smoke.yml) pre-build for - # the same reason. - - name: Build @objectstack/client and its dependencies - run: pnpm exec turbo run build --filter=@objectstack/client... --concurrency=4 - - # Keyed on every BUILD INPUT the `filter` job's `console` list names (the - # pin, the build script, the two scripts that derive and stamp the injection - # probes, the spec's entry layout, the six other packed manifests) and on nothing else. - # scripts/check-ci-filter-parity.mjs holds this key and that list in step - # (#20765). A head that moves a build input MISSES, so the build and the - # assertion inside it judge the change on the PR head, not first in the - # merge queue. A head that moves only a guard hits, which is what keeps - # those triggers cheap. A PR reads what main, or its own earlier pushes, - # saved under the same key. - # - # ⚠️ release.yml's restore step still keys on the pin and the build script - # alone, so the two workflows no longer share dist entries: each builds its - # own on a miss. The narrower release key is deliberate, and that file's - # comment says why (#20765). - # - # Split restore/save where release.yml uses the combined action, because the - # combined form's post-step saves even when the job FAILED. build-console.sh - # writes the SHA stamp before it asserts the bundle canary, so a canary - # failure leaves a stamped-but-broken dist on disk — cache that and the next - # run restores it, skips the build, and sails through the stamp check. Saving - # only once every assertion below is green is what lets a restored dist carry - # the same guarantees as a freshly built one. - - name: Restore vendored Console dist (keyed on the pin and its other build inputs) - id: console-dist - uses: actions/cache/restore@v6 - with: - path: packages/console/dist - key: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh', 'scripts/assert-console-spec-injection.mjs', 'scripts/console-spec-probes.mjs', 'packages/spec/package.json', 'packages/spec/tsup.config.ts', 'packages/core/package.json', 'packages/types/package.json', 'packages/client/package.json', 'packages/formula/package.json', 'packages/sdui-parser/package.json', 'packages/lint/package.json') }} - - # The gate. Shallow-clones objectui at the pinned SHA, builds - # @object-ui/console against this tree's client, copies the dist into - # packages/console/, and asserts the bundle canary — every failure mode a - # bad pin can carry, surfaced on the PR instead of at publish time. - - name: Build the Console SPA at the pinned objectui SHA - if: steps.console-dist.outputs.cache-hit != 'true' - run: bash scripts/build-console.sh - - # check:console-sha exits 0 — deliberately — when there is no dist at all - # (published installs and package-only CI legitimately have none), and warns - # without failing when a dist carries no stamp. Both are right for that - # script and both are VACUOUS here, where producing a dist is the entire - # point: an empty or partial cache restore would otherwise reach the drift - # check with nothing to check and report success. Prove one is really in - # place before letting that check speak for the job. - - name: Assert a real Console dist is in place - env: - CACHE_HIT: ${{ steps.console-dist.outputs.cache-hit }} - run: | - dist=packages/console/dist - if [ "$CACHE_HIT" = "true" ]; then origin="the dist cache restore"; else origin="scripts/build-console.sh"; fi - fail=0 - [ -s "$dist/index.html" ] || { echo "::error::$dist/index.html is missing or empty"; fail=1; } - [ -n "$(ls -A "$dist/assets" 2>/dev/null || true)" ] || { echo "::error::$dist/assets is missing or empty"; fail=1; } - [ -s "$dist/.objectui-sha" ] || { echo "::error::$dist/.objectui-sha stamp is missing — check:console-sha would pass vacuously"; fail=1; } - if [ "$fail" -ne 0 ]; then - echo "::error::No usable Console dist after $origin." - exit 1 - fi - echo "✓ Console dist present ($(du -sk "$dist" | awk '{print $1}') KB, via $origin) from objectui@$(cut -c1-12 "$dist/.objectui-sha")" - - - name: Verify the Console dist stamp matches the pin - run: pnpm check:console-sha - - # The spec-injection half of the same question, and the reason it is a - # SEPARATE step from build-console.sh (#9667). - # - # The dist cache key carries only the spec's ENTRY LAYOUT (package.json and - # tsup.config.ts, see the restore step). The rest of packages/spec is - # deliberately NOT in it, because adding it would bust the key on every spec - # change and force a ~20 min cold console rebuild on a repo doing ~18 merges - # a day. The cost model stays; what does not stay is the - # silence. scripts/assert-console-spec-injection.mjs runs INSIDE - # build-console.sh, so the `if: cache-hit != 'true'` above skips it exactly - # when the dist was NOT built here — which is the run that most needs asking. - # - # That script cannot simply be re-run on this path: it derives its stale - # detector from the PUBLISHED @objectstack/spec in objectui's own lockfile, - # and on a cache hit there is no objectui clone to read it from. So the build - # stamps the probes it chose into dist/.objectstack-injection.json and this - # step replays them against whatever is actually on disk — one node process, - # no network, cheap enough to run on every PR (which is the whole point; a - # gate too expensive to always run is a gate that gets skipped). - # - # --require-stamp because the vacuity that check:console-sha tolerates is - # wrong here for the same reason the presence assert above exists: an - # unstamped dist is one no build ever proved, including one saved by a run - # that died before the assertion. release.yml uses the COMBINED cache action, - # whose post-step saves even on failure, under this same repo-scoped key — so - # a poisoned entry is reachable from here and must not pass quietly. - - name: Verify the restored Console dist bundles this tree's spec - env: - CONSOLE_DIST_CACHE_KEY: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh', 'scripts/assert-console-spec-injection.mjs', 'scripts/console-spec-probes.mjs', 'packages/spec/package.json', 'packages/spec/tsup.config.ts', 'packages/core/package.json', 'packages/types/package.json', 'packages/client/package.json', 'packages/formula/package.json', 'packages/sdui-parser/package.json', 'packages/lint/package.json') }} - run: pnpm check:console-injection --require-stamp - - # Reached only with every assertion above green (see the split-restore note). - # This deviates from the workflow's restore-only-on-PRs policy on purpose: - # that policy targets the ~5 turbo entries every push writes, whereas this - # key moves only when the pin does. A pin-bump PR writes one entry, and the - # rest of that PR's pushes reuse it instead of rebuilding the console again. - - name: Save vendored Console dist - if: steps.console-dist.outputs.cache-hit != 'true' - uses: actions/cache/save@v6 - with: - path: packages/console/dist - key: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh', 'scripts/assert-console-spec-injection.mjs', 'scripts/console-spec-probes.mjs', 'packages/spec/package.json', 'packages/spec/tsup.config.ts', 'packages/core/package.json', 'packages/types/package.json', 'packages/client/package.json', 'packages/formula/package.json', 'packages/sdui-parser/package.json', 'packages/lint/package.json') }} - # ── THE ui/ JOBS (objectstack#25697, epic objectstack#25677) ────────────── # objectui is imported under `ui/` as a subtree. These four jobs are its CI, # ported from objectui's own ci.yml (`Type Check`, `Test (shard n/16)`, diff --git a/.github/workflows/cut-rc.yml b/.github/workflows/cut-rc.yml index a398c4a3c00..4fef6bf9a31 100644 --- a/.github/workflows/cut-rc.yml +++ b/.github/workflows/cut-rc.yml @@ -255,7 +255,7 @@ jobs: restore-keys: | ${{ runner.os }}-pnpm-store-v3- - # Restore-only, like ci.yml's Console Pin Gate: this workflow runs a handful + # Restore-only, as ci.yml's retired Console Pin Gate was: this workflow runs a handful # of times a month so its own namespace is almost always cold, and the # build-core fallbacks seeded from main are the ones that actually hit. # Saving is lint.yml's job. diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index d2c2c3fa5da..8e00922bf1e 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -57,10 +57,9 @@ on: # snapshot owns :07 of 02, 08, 14 and 20. Of the six six-hour sets, only # 0/6/12/18 and 1/7/13/19 have hours with nothing scheduled after :00; the # first is taken because its :00 neighbours are short (release.yml's - # version-pr refresh, prerelease-pin-watch at 06:00, the hourly ci/lint - # runs), while 07:00 starts the showcase smoke. The MINUTE is off the top of - # the hour for the reason every patrol here gives: scheduled runs queue - # behind everyone else's :00 cron. + # version-pr refresh, the hourly ci/lint runs), while 07:00 starts the + # showcase smoke. The MINUTE is off the top of the hour for the reason every + # patrol here gives: scheduled runs queue behind everyone else's :00 cron. # # This job spends almost nothing of the per-repository GITHUB_TOKEN budget # (the checkout, and no API reads), so unlike the board readers it is not diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a8d951d16a8..9cfb886055b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3261,7 +3261,7 @@ jobs: # 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 + # loud-but-non-blocking shape the retired prerelease-pin-watch.yml used — 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 @@ -4727,7 +4727,7 @@ jobs: # 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 + # on every PR from the since-retired `check-prerelease-pin-watch` and minted 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. # diff --git a/.github/workflows/prerelease-pin-watch.yml b/.github/workflows/prerelease-pin-watch.yml deleted file mode 100644 index 361fe37111a..00000000000 --- a/.github/workflows/prerelease-pin-watch.yml +++ /dev/null @@ -1,130 +0,0 @@ -name: Prerelease Pin Watch - -# Has upstream shipped a STABLE release that retires one of our prerelease -# `overrides` pins? (#5024) -# -# WHAT IT WATCHES, AND WHY IT IS A WORKFLOW AT ALL -# ----------------------------------------------- -# `pnpm-workspace.yaml` pins the better-auth family to `1.7.0-rc.2` (scim one rc -# behind at `1.7.0-rc.1`) and promises, in a comment: -# -# revert to a stable `^1.7.x` line the moment one ships. -# -# #3002 (revert the family) and #3653 (the SCIM migration) are both gated on that -# event, and until this workflow existed NOTHING watched for it — redeeming the -# promise depended on a person remembering to check npm. That is the repo's -# `declared != enforced` shape applied to a comment, and this workflow is the -# missing producer. It never edits a pin; it only makes the trigger arrive as a -# signal within a day instead of as a memory. -# -# The watch list is DERIVED from the pins (every override whose target is a -# prerelease version), so it cannot drift from the file it polices, and it empties -# itself when the last prerelease pin goes stable. See the script header. -# -# THREE EXIT CODES, ON PURPOSE -# ---------------------------- -# 0 no stable release yet → quiet, one line -# 1 a stable release EXISTS → job RED, report names #3002 / #3653 -# 2 a registry read failed → `::warning::` + step summary, job GREEN -# -# Exit 2 is the considered trade-off, not an oversight. This is an unattended -# nightly: a transient npm 5xx that turns it red teaches everyone to skim it, and -# a nightly nobody reads is exactly the "nobody is watching" state #5024 is about. -# Missing one night costs at most a day — the next run re-probes from scratch and -# a published version is never unpublished back into silence. So red keeps meaning -# exactly one thing here (a stable release is out), and an inconclusive probe is -# loud-but-non-blocking instead of being silently reported as "no release yet", -# which is the one thing it must never do. `--strict` promotes exit 2 to exit 1 if -# the warnings ever start being missed. -# -# (Contrast a blocking release gate that reads a remote, where an unreadable -# remote must FAIL, because silence there ships the defect the gate exists to -# catch. The repo's example of that shape used to be `check:objectui-pin-fresh`; -# it was deleted in #10134 and the contrast is kept as a rule, not a pointer.) -# -# WHY NOT lint.yml / validate-deps.yml -# ------------------------------------ -# Not `lint.yml`: the probe needs the network, and no required PR gate in this -# repo may depend on a third-party registry being up. Not `validate-deps.yml` -# either, close as it is in subject matter — it is scheduled WEEKLY (Mon 03:00 -# UTC), and the acceptance criterion here is a signal within the first working -# day of the release. A weekly slot can sit on the event for six days. -# -# On a PR that touches the probe, the pins, or this file, only the offline -# `--self-test` runs ("a change to the guard runs the guard"). No `pnpm install` -# anywhere: the script is dependency-free by design, which is what keeps this -# whole workflow a ~15-second job. - -on: - schedule: - # 06:00 UTC nightly — after Spec Coverage (05:00), before Showcase Smoke - # (07:00). Nightly, not weekly: see the note above. - - cron: '0 6 * * *' - workflow_dispatch: - pull_request: - paths: - - 'scripts/check-prerelease-pin-watch.mjs' - - '.github/workflows/prerelease-pin-watch.yml' - # The pins ARE the watch list, so a PR that edits them re-runs the - # self-test that proves the real file still parses into one. - - 'pnpm-workspace.yaml' - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - watch: - name: Stable release watch for prerelease pins - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: '22' - - # "A change to the guard runs the guard." Offline and hermetic — fabricated - # registry responses cover all three states plus the empty watch list — so - # it is safe on a PR where the probe itself would not be. - - name: Self-test the probe - run: node scripts/check-prerelease-pin-watch.mjs --self-test - - - name: Probe is nightly-only on PRs - if: github.event_name == 'pull_request' - run: | - echo "::notice::Self-test only on a PR — the probe itself reads registry.npmjs.org, and no PR gate here may depend on a third-party registry. It runs nightly at 06:00 UTC (#5024)." - - - name: Probe npm for a stable release - if: github.event_name != 'pull_request' - run: | - set -o pipefail - status=0 - node scripts/check-prerelease-pin-watch.mjs --verbose 2>&1 \ - | tee "$RUNNER_TEMP/prerelease-pin-watch.log" || status=$? - - { - echo '### Prerelease pin watch (#5024)' - echo - echo '```' - cat "$RUNNER_TEMP/prerelease-pin-watch.log" - echo '```' - } >> "$GITHUB_STEP_SUMMARY" - - if [ "$status" -eq 0 ]; then - exit 0 - elif [ "$status" -eq 2 ]; then - # Loud but non-blocking — see the header for why this is not red. - # The script has already printed its own ::warning:: with the reason. - echo "::warning::Prerelease pin watch was INCONCLUSIVE (a registry read failed). This is NOT 'no stable release yet'. It re-probes tomorrow; if it stays inconclusive, run this workflow with --strict wired in and investigate (#5024)." - exit 0 - fi - - echo "::error::A STABLE release now exists for a pin this repo holds at a prerelease. The trigger condition of #3002 / #3653 has arrived — see the step summary for the per-package verdicts and the action list (#5024)." - exit "$status" diff --git a/.github/workflows/release-coverage-patrol.yml b/.github/workflows/release-coverage-patrol.yml index d3f5c5b1dc6..e30263e34e9 100644 --- a/.github/workflows/release-coverage-patrol.yml +++ b/.github/workflows/release-coverage-patrol.yml @@ -63,7 +63,7 @@ name: Release-Coverage Patrol # grepping prose, while the JOB stays green whenever findings exist. A `--strict` # run stays red for as long as the prose is unwritten — 25 days, in the 16.1.0 # case — and a chronically red scheduled job is how people learn to skim red, -# which `prerelease-pin-watch.yml`'s header warns about directly. Findings land +# which the retired `prerelease-pin-watch.yml`'s header warned about directly. Findings land # in the anchor issue; they never colour this job. # # ## What lands where diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35dc1b22620..7e2f1530374 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1506,8 +1506,8 @@ jobs: # ══════════════════════════════════════════════════════════════════════════ full-battery: name: Full battery green at the version commit - # A PR or queue build carries ci.yml's FAST SET since #22998; the dogfood, - # temporal and console-pin legs run only on main's push, hourly and + # A PR or queue build carries ci.yml's FAST SET since #22998; the dogfood + # and temporal legs run only on main's push, hourly and # dispatch runs. So "tested before a release" is read HERE, as a gate: the # newest completed `schedule` or `push` runs of `CI` and `Lint & Type # Check` whose head is the version commit or a first-parent ancestor of it diff --git a/.github/workflows/spec-shape-gate.yml b/.github/workflows/spec-shape-gate.yml deleted file mode 100644 index fe8c792cb56..00000000000 --- a/.github/workflows/spec-shape-gate.yml +++ /dev/null @@ -1,339 +0,0 @@ -name: Spec Shape Gate - -# Compile the downstream consumer -- objectstack-ai/objectui at `main` -- against -# `@objectstack/spec` (and every other `@objectstack/*` package it installs) -# built from THIS commit, and name the objectui file, the objectstack commit and -# the owning lane when it does not compile. -# -# ## The ruling this executes -# -# Ruling B on the decision card for the spec shape gate (#23238, comment -# 6145029395, batch 336 item 3, 2026-10-13): the gate MOVES to objectstack -- -# hourly on `main` with the two-speed full battery, read by the release gate, -# a red attributed to the lane that moved the shape; objectui drops its copy -# once two consecutive hourly runs here agree with objectui's. The MECHANISM of -# ruling C on #19011 (consumer compilation as the ONE shape gate for the spec's -# public surface) is kept unchanged; what moved is where it runs and who owns a -# red. ⛔ Neither direction is re-adjudicated here. -# -# The objectui half -- `.github/workflows/spec-main-shape-gate.yml` there, its -# script `scripts/spec-main-shape-gate.mjs` and its three pins -- was deleted -# in objectstack-ai/objectui PR #12596 (card objectstack-ai/objectui#12352), so -# this is the only spec shape gate and nothing runs in parallel. Both files are -# readable at objectui commit `7d0c174ba8383d08b8dd1d4f7d7845009f1bf2fd`, the -# first parent of that PR's merge commit `c6a42be392` and the last objectui -# `main` commit that has them. This file is the objectstack port of that -# workflow with the direction reversed: the spec is built from the checkout the -# run is in, the CLONE is the downstream, and there is no artifact reuse -- the -# spec is always the one from the tree under test. -# -# ## Its place in the two speeds (#22998) -# -# `schedule`, hourly on `main`, is this gate's place in the FULL BATTERY. A PR -# or queue build carries the fast set; this job never runs on an ordinary pull -# request (two-speed rule 1) and is NOT subscribed to `merge_group`. The one -# `pull_request` trigger below is path-filtered to this gate's own three files, -# so a change to the gate -- the workflow, the script, or a REGISTRATION in -# the retirement registry -- is exercised before it merges and proves on its -# own pull request that the registered diagnosis reads `known`; no other pull -# request pays the roughly thirteen minutes of a downstream compile. -# -# `scripts/ci/scheduled-full-run.mjs --release-gate` reads this workflow's -# newest `schedule` run at or before the version commit, beside `CI`'s and -# `Lint & Type Check`'s, and the publish lane refuses unless all conclude -# `success` (`RELEASE_GATE_WORKFLOWS` there is the registration). A run whose -# every diagnostic is a registered retirement concludes `success`. -# -# ## Who owns a red -# -# ⭐ A red on the hourly run is a MAIN-RED under #22998 rule 3: it is the FIRST -# WORK of the lane the report names. The report step attributes it to the -# objectstack commits since this workflow's last green run on `main`, the pull -# request each landed in, and the `domain:*` lane of the card that pull fixes; -# a lane it cannot read is written `unattributed` -- a reading this gate could -# not take, never a lane with no owner. An INTENTIONAL retirement is not a red -# for long: the accepting seat files the downstream card and registers the -# retirement in `scripts/ci/spec-shape-retirements.json` in the same act -# (cross-repo rule 3), and the next run reads the matching diagnostics as -# `known (registered: )` and does not red. ⛔ The registry is one line per -# retirement with a per-diagnostic pattern; it is not, and must never become, -# an ignore-all switch -- `scripts/ci/spec-shape-gate.mjs` (THE REGISTRY) -# refuses an entry that would be one. -# -# ⭐ WHAT A RED MUST SAY is the acceptance criterion, not decoration: which -# objectui file fails against which objectstack commit, so that the objectstack -# pull request which moved the shape is the one that answers. The script owns -# that half -- it attributes every diagnostic to a repo-relative objectui path -# paired with the objectstack sha, in the run summary and in annotations -- and -# it refuses (exit 2, loudly) to report an unattributable failure as a shape -# break or as a pass. -# -# ## Why the clone is objectui `main`, not the pin -# -# The question this gate asks is "does TODAY'S downstream compile against this -# commit". `.objectui-sha` is the release artifact's pin and stays the Console -# Pin Gate's question (ci.yml `console-pin`); the two gates are not the same -# question and neither replaces the other. -# -# ## ⛔ The turbo cache is a FALSE GREEN here -# -# `turbo`'s hash covers objectui's sources, lockfile and a declared env list, -# not the CONTENT of `node_modules`, which is the only thing this job changes, -# so a cached `type-check` could replay a verdict taken against the PUBLISHED -# spec. The typecheck runs with `TURBO_FORCE=true` and no turbo cache action. -# `--continue` is part of the READING: without it turbo stops scheduling at the -# first failed task and the log holds one package's diagnostics instead of -# every package's. -# -# ## Two runner details that change what a reader sees -# -# `actions/setup-node` registers a `tsc` problem matcher; left on, the runner -# parses the echoed typecheck log and mints a second annotation per diagnostic -# with a path that does not resolve in this repository (measured on objectui's -# own runs: ten extra annotations re-homed onto `.github`). The compile step -# removes that matcher before echoing the log, so the only annotations are the -# report's, each naming the objectui path and the objectstack sha in its title. -# The exit code of the compile is captured with NO PIPE in between. -# -# The downstream's install runs its lifecycle scripts, as any consumer compile -# must; the token this job holds is read-only (`permissions` below) and nothing -# here writes through it. - -on: - # The full battery's hourly run on `main`. Off the hour on purpose -- ci.yml - # and lint.yml take `:00`, and scheduled workflows queue behind everyone's - # `:00` cron -- and on a minute no sibling here uses. - schedule: - - cron: '52 * * * *' - # The same reading on demand: an operator who has just registered a - # retirement or landed a fix should not wait an hour to see it answered. - workflow_dispatch: {} - # This gate's own files only. See "Its place in the two speeds" above. - pull_request: - paths: - - '.github/workflows/spec-shape-gate.yml' - - 'scripts/ci/spec-shape-gate.mjs' - - 'scripts/ci/spec-shape-retirements.json' - -# Least privilege, all reads: `contents` for the two checkouts, `actions` for -# the report's lookup of this workflow's own earlier runs (the last green), -# `pull-requests` and `issues` for the lane attribution (a pull's labels and -# body, a card's labels). Nothing writes through the token. -permissions: - contents: read - actions: read - pull-requests: read - issues: read - -# `github.event_name` is in the key on purpose: a `schedule` run and a -# `workflow_dispatch` run on `main` carry the same `github.ref`, and neither -# may cancel the other -- the hourly run is the reading the release gate waits -# on, so a cancellation there is a missing reading rather than a saved minute. -# Superseded pull-request runs are cancelled; runs on `main` never are. -concurrency: - group: spec-shape-gate-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -jobs: - shape-gate: - name: Spec Shape Gate - runs-on: ubuntu-latest - # Sized against objectui's measured runs (about thirteen minutes, of which - # the forced, uncached downstream build + typecheck is eleven) with room - # for a cold runner; a ceiling this job can reach turns a verdict into - # `cancelled`, which reports nothing a reader can act on. - timeout-minutes: 45 - - steps: - - name: Checkout objectstack (the commit under test) - uses: actions/checkout@v7 - - # The downstream, at `main`, inside the workspace (actions/checkout - # refuses a path outside it). objectstack's workspace globs are rooted at - # `packages/`, `apps/` and `examples/`, so nothing under `objectui/` is - # swept into this repository's install, and pnpm run inside `objectui/` - # finds objectui's own `pnpm-workspace.yaml` first. - - name: Checkout objectui main (the downstream under test) - uses: actions/checkout@v7 - with: - repository: objectstack-ai/objectui - ref: main - path: objectui - fetch-depth: 1 - - # Resolved ONCE, into outputs every later step and the report quote, so - # the sha in the summary is provably the sha that was built. - - name: Resolve the two commits under test - id: subjects - run: | - set -euo pipefail - upstream=$(git rev-parse HEAD) - downstream=$(git -C objectui rev-parse HEAD) - subject=$(git log -1 --format=%s HEAD) - committed=$(git log -1 --format=%cI HEAD) - echo "upstream=$upstream" >> "$GITHUB_OUTPUT" - echo "downstream=$downstream" >> "$GITHUB_OUTPUT" - echo "commit=${upstream:0:12} -- $committed -- $subject" >> "$GITHUB_OUTPUT" - echo "objectstack@$upstream ($committed) against objectui@$downstream" - - - 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" - - # One store for both installs (same pnpm, same store path), keyed on both - # lockfiles. Restore here; the save below runs only on `main` runs, so an - # hourly run seeds the next hour and a pull-request run spends nothing - # from the cache pool. ci.yml's store entries are the cold fallback. - - name: Restore the pnpm store - id: store - uses: actions/cache/restore@v6 - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-spec-shape-gate-pnpm-store-${{ hashFiles('pnpm-lock.yaml', 'objectui/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-spec-shape-gate-pnpm-store- - ${{ runner.os }}-pnpm-store-v3- - - # "A change to the gate runs the gate's self-test": offline, no install. - - name: Self-test the gate script - run: pnpm check:spec-shape-gate - - # ── Build what objectui installs, from THIS commit, and PACK the spec ── - # objectui consumes every `@objectstack/*` package BY COMMIT: its root - # `package.json` overrides each to `file:node_modules/.cache/objectstack/ - # `, which its `scripts/objectstack-pin.mjs build` fills (below, with - # OBJECTSTACK_ROOT naming this checkout). The names are read from those - # overrides, the list the pin script packs; `--filter-prod` keeps the - # closure to runtime edges as that script's own build does (`...` alone - # would pull in the client's dev closure). The install brings the root's - # toolchain too, and `inject` later reads the dependencies it resolved - # (`packages/spec/node_modules`) to re-point a store sibling the packed - # manifest's range refuses. - - name: Install the closure of the @objectstack/* packages objectui installs by commit - run: | - set -euo pipefail - filters=$(node -e ' - const m = JSON.parse(require("fs").readFileSync("objectui/package.json", "utf8")); - const names = Object.keys(m.pnpm?.overrides ?? {}).filter((n) => n.startsWith("@objectstack/")).sort(); - if (names.length === 0) { console.error("objectui/package.json overrides no @objectstack/* package"); process.exit(2); } - process.stdout.write(names.map((n) => `--filter-prod=${n}...`).join(" "));') - echo "PINNED_FILTERS=$filters" >> "$GITHUB_ENV" - pnpm install --frozen-lockfile $filters - - - name: Build them from this commit - run: pnpm $PINNED_FILTERS run build - - # What gets injected is what a RELEASE from this commit would ship -- the - # `files` field's file set, not a working tree. `--ignore-scripts` - # because the build above already ran. - - name: Pack the built spec (the file set a release from this commit would ship) - id: pack - working-directory: packages/spec - run: | - set -euo pipefail - mkdir -p "$RUNNER_TEMP/spec-built" - packed=$(npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP/spec-built" | tail -n 1) - echo "tarball=$RUNNER_TEMP/spec-built/$packed" >> "$GITHUB_OUTPUT" - ls -l "$RUNNER_TEMP/spec-built/$packed" - - # ── The downstream's install ───────────────────────────────────────── - # Corepack reads the nearest `packageManager` pin, so pnpm invoked inside - # `objectui/` is objectui's pinned version (downloaded here, once, when - # it differs from this repository's). A named step so a toolchain - # download that fails is answerable from the checks list alone. - - name: Materialise objectui's pinned pnpm - working-directory: objectui - run: pnpm --version - - # objectui's `objectstack:build` script (its pin script's `build`) packs - # every package built above into the directories its overrides name; - # without it the frozen install dies on ENOENT there. Called by objectui's - # script NAME: a path here would read as one in this repository. It - # refuses (exit 1, its own `objectstack-pin` annotation) when a packed - # manifest's dependency NAMES differ from what objectui's lockfile records: - # the downstream owes a pin bump with its lockfile, and the report reads the - # missing compile as exit 2. A changed peer, engines or dependency range is - # not refused there -- a frozen install links it and exits 0 -- and the - # spec's ranges stay `inject`'s to judge. - - name: Pack this commit's @objectstack/* into objectui's pinned-build directories - working-directory: objectui - env: - OBJECTSTACK_ROOT: ${{ github.workspace }} - run: pnpm run objectstack:build - - - name: Install objectui dependencies - working-directory: objectui - run: pnpm install --frozen-lockfile - - - name: Save the pnpm store - if: github.event_name != 'pull_request' && steps.store.outputs.cache-hit != 'true' - uses: actions/cache/save@v6 - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-spec-shape-gate-pnpm-store-${{ hashFiles('pnpm-lock.yaml', 'objectui/pnpm-lock.yaml') }} - - # Replaces the installed spec in objectui's pnpm virtual store -- REMOVE - # then copy, because those files are hardlinks into the global store -- - # re-points a store sibling the packed manifest's range refuses at the - # copy this tree's spec build resolved, and proves every workspace - # consumer resolves the injected copy before returning. Nothing tracked - # by git is touched in either checkout. The pinned build above already - # installed this commit's spec (the same declaration files); what this - # step still adds is that range judgement and the consumer proof, which - # the pin script does not make (it compares dependency names only). - - name: Inject the source-built spec into objectui's install - env: - UPSTREAM_SHA: ${{ steps.subjects.outputs.upstream }} - TARBALL: ${{ steps.pack.outputs.tarball }} - run: node scripts/ci/spec-shape-gate.mjs inject --tarball "$TARBALL" --sha "$UPSTREAM_SHA" --upstream-checkout "$GITHUB_WORKSPACE" --downstream "$GITHUB_WORKSPACE/objectui" - - # ── The compile ────────────────────────────────────────────────────── - # `pnpm type-check` is objectui's `turbo run type-check`, whose task - # dependsOn `^build`: every objectui package is built against the - # injected spec and then type-checked against those declarations. See the - # header for `TURBO_FORCE`, `--continue`, the matcher and the no-pipe - # capture. The step itself always exits 0; the verdict is the report's. - - name: Type-check objectui against it - id: compile - working-directory: objectui - run: | - set +e - echo "::remove-matcher owner=tsc::" - TURBO_FORCE=true pnpm type-check --continue > "$RUNNER_TEMP/typecheck.log" 2>&1 - code=$? - set -e - echo "status=$code" >> "$GITHUB_OUTPUT" - cat "$RUNNER_TEMP/typecheck.log" - exit 0 - - # ALWAYS: this step IS the delivery. It reads the registry, writes the - # summary (new diagnostics, known ones with their registration, the - # commit range and lanes), emits one annotation per NEW diagnostic naming - # the objectui file and the objectstack commit, and carries the verdict: - # exit 0 green or known-only, exit 1 a new shape break, exit 2 a reading - # it could not take (never a pass, never a shape break). - - name: Report which objectui file fails against which objectstack commit, known or new, and whose lane - if: always() - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - UPSTREAM_SHA: ${{ steps.subjects.outputs.upstream }} - DOWNSTREAM_SHA: ${{ steps.subjects.outputs.downstream }} - UPSTREAM_COMMIT: ${{ steps.subjects.outputs.commit }} - COMPILE_STATUS: ${{ steps.compile.outputs.status }} - run: | - node scripts/ci/spec-shape-gate.mjs report \ - --log "$RUNNER_TEMP/typecheck.log" \ - --sha "$UPSTREAM_SHA" \ - --downstream "$GITHUB_WORKSPACE/objectui" \ - --downstream-sha "$DOWNSTREAM_SHA" \ - --commit "$UPSTREAM_COMMIT" \ - --event "$GITHUB_EVENT_NAME" \ - --status "${COMPILE_STATUS:-2}" diff --git a/AGENTS.md b/AGENTS.md index 3610d52fd79..42da7cb4065 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -530,7 +530,7 @@ Even inside your own worktree, operate defensively: check-run name, so a rename detaches its gate silently — treat those five names as contract. **Two speeds** (ruling record 6120820653): PR and queue runs carry the fast set, 10 minutes of wall per queue entry; `main`'s hourly and push runs carry the full battery — dogfood, e2e, - `Console Pin Gate`, `Temporal Conformance (live PG + MySQL)` — read by the publish lane before + `Temporal Conformance (live PG + MySQL)` — read by the publish lane before any approval. **Main-red:** a full-battery red is its lane's first work — triage files it under the main-red convention, the lane's next PR fixes it, and ⛔ no lane PR arms the queue meanwhile. @@ -1100,8 +1100,8 @@ Both non-handshake shapes, and how to classify and probe your own: (`pnpm check:adr-0087-registration` reads the arm and the kind there). ⛔ The categories are NOT copied here — the gate prints the full set when it fails. 4. **A removal that breaks the pinned sibling checkout ships together with the sibling fix and the pin bump — or it - does not ship.** The full battery's `Console Pin Gate` builds objectui at the pinned `.objectui-sha` against - **current** `main`, so such a removal is a main-red the moment it merges; its ruling therefore authorizes the + does not ship.** `Type Check UI` compiles the Console under `ui/` against the spec of the PR that makes such a + removal, so the removal reds its own PR; its ruling therefore authorizes the objectui-side fix and the pin bump (manifest regeneration included — see the Frontend section) in the same landing. Before merging, `git grep` what you remove or rename in `../objectui` at the pinned SHA. 5. **Touched `packages/spec`? Regenerate and commit its artifacts before pushing** — § *Touched `packages/spec`* diff --git a/docs/releases-maintenance.md b/docs/releases-maintenance.md index abe649d1135..098289a2206 100644 --- a/docs/releases-maintenance.md +++ b/docs/releases-maintenance.md @@ -270,10 +270,11 @@ path. The 2026-08-20 ruling removed it (#10134): the workflow, the script and th *everything shipped is covered by the changeset record* — is delivered by step 3 above, at bump time, which is where the decision is actually taken. -**What still checks the pin, and what it checks.** `ci.yml`'s **Console Pin Gate** -(#4290) clones objectui at the pin and builds the SPA, so it proves the pinned SHA -still **builds**. It has never had an opinion about whether the pin is current, and -that is now the only question anyone asks about the pin automatically. +**What used to check the pin.** `ci.yml`'s **Console Pin Gate** (#4290) cloned +objectui at the pin and built the SPA, so it proved the pinned SHA still +**built**; it never had an opinion about whether the pin was current. It retired +with the objectui import (#25697): `Type Check UI` and `Build & E2E UI` in +`ci.yml` build the Console from `ui/` in the pull request that changes it. ## Rehearsing the version pass (throwaway clone) @@ -472,7 +473,7 @@ is changed by where the refresh came from. **Then, before approving, check that no nearer `CI` run is still in flight** (#24889). Open the Release run's **`Full battery green at the version commit`** job, step *Read the full battery at or before the version commit*. It prints one line per workflow it -reads (`CI`, `Lint & Type Check`, `Spec Shape Gate`), each naming the run it chose and +reads (`CI`, `Lint & Type Check`), each naming the run it chose and how many first-parent commits before the version commit that run's head sits. The gate reads only *completed* runs and never waits (by design: `selectFullBatteryRun` in `scripts/ci/scheduled-full-run.mjs`), so a run still in progress is invisible to diff --git a/package.json b/package.json index 2b56397570a..56529b33acd 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,6 @@ "check:pm-governed-prose": "node scripts/pm/check-governed-prose.mjs --self-test && node scripts/pm/check-governed-prose.mjs", "check:pm-settings-deny-roster": "node scripts/pm/check-settings-deny-roster.mjs --self-test && node scripts/pm/check-settings-deny-roster.mjs", "check:pm-expected-skips": "node scripts/pm/check-expected-skips.mjs --self-test", - "check:spec-shape-gate": "node scripts/ci/spec-shape-gate.mjs --self-test", "check:publish-smoke-pin": "node scripts/publish-smoke-pack.mjs --self-test", "check:partof-closing-keyword": "node scripts/check-partof-closing-keyword.mjs --self-test", "check:commit-card-trailers": "node scripts/check-commit-card-trailers.mjs --self-test", diff --git a/scripts/check-ci-filter-parity.mjs b/scripts/check-ci-filter-parity.mjs index c81a1a10df8..9162ef38272 100644 --- a/scripts/check-ci-filter-parity.mjs +++ b/scripts/check-ci-filter-parity.mjs @@ -112,38 +112,12 @@ * NOTHING the table declares, though the table is precisely this gate's * population. Measured on 589758d22: 1 (gate, file) pair before, 3253 after. * - * ## The second subject: the `console` selection and the dist key it must move (#20765) + * ## The second subject, retired: the `console` selection and its dist key (#20765) * - * The same filter job schedules `Console Pin Gate` from a second hand-kept list, - * `console:`, and that job restores its console dist from a cache keyed on - * `hashFiles(...)`. The two lists answer different questions and each has a hole - * the other one cannot see: - * - * - A path the KEY hashes but the FILTER does not name moves the key without - * starting the job, so the first build under the new key is the merge - * queue's, where a check outside the required set cannot stop a merge. - * - A path the FILTER names but the KEY does not hash starts the job, and the - * job then restores the dist built before the change and skips the build - * step, which is where the build-time assertion - * (`scripts/assert-console-spec-injection.mjs`) lives. The run is green - * without judging the change. That is only right for a GUARD: code every run - * executes, hit or miss. `CONSOLE_GUARDS` declares those, each with the step - * that runs it. - * - * Measured on the #20695 shape (the migration registries moved to a new spec - * entry): its queue build missed the cache, rebuilt, and went red with "Neither - * spec appears in the built console"; a fixture replay of the same head on the - * cache-hit path (the restored pre-move dist, its stamp, the post-move tree) - * exits 0. So the job judges an entry-layout change only when the key moves too. - * - * So `judgeConsole` holds five things of the checked-in ci.yml: every `console` - * entry is a literal path (with no pattern in the list, a path selects the job - * exactly when the list names it, which is what lets the self-test pin the - * selection, and a pattern under `packages/spec` would widen the job to every - * spec change); every key spelling in the job is one string; every hashed path - * is a `console` entry; every `console` entry is hashed or a declared guard; and - * every declared guard is still in the list and not hashed. Pure string again, - * for the reason above: no matcher, so no third recognizer. + * This gate also held the `console:` filter list in step with the dist-cache key + * of the `console-pin` job (Console Pin Gate). Both left ci.yml with the objectui + * pin (#25697): the Console is a workspace under `ui/`, built in the same tree as + * the spec, so there is no vendored dist to key and no second list to hold. * * ## The third subject: Build Core and the build inputs turbo.json declares (#21202) * @@ -195,8 +169,8 @@ * matched no `core` entry, so Build Core skipped and the changed guard ran * nowhere before the merge queue: PR #17100 changed only * `scripts/check-lean-entry-closure.mjs`, and its Build Core concluded - * `skipped`. `console:` already applies "a change to the guard runs the guard" - * to its job (`CONSOLE_GUARDS` above); `core:` had no such rows. + * `skipped`. `core:` had no rows applying "a change to the guard runs the + * guard" to its job, as the retired `console:` list did for its own. * * This population is DERIVED from the job, the way the build inputs are * derived from turbo.json, so no list of guards is kept here: every `run:` @@ -276,14 +250,15 @@ const SELF_TEST_BATTERIES = Object.freeze({ '(5) refusals: never a clean zero over a subject that was not read': 10, '(6) the real tree': 10, '(7) WIRING: the gate and its self-test really run in CI': 2, - '(8) the `console` selection and the dist key it must move': 20, + // (8), the `console` selection and its dist key, retired with its subject + // (see the header); the numbers below keep their names. '(9) Build Core and the build inputs turbo.json declares': 42, '(10) Build Core and the scripts its own steps run': 30, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 10; +const SELF_TEST_BATTERY_FLOOR = 9; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -468,172 +443,6 @@ export function judge(source, table) { return { filters: read.filters, condition: read.condition, declarations, covered, uncovered, stale }; } -// ── The second subject: the `console` selection and its dist key (#20765) ──── - -/** The filter list that schedules the console job, and that job, by id. */ -export const CONSOLE_FILTER = 'console'; -export const CONSOLE_JOB = 'console-pin'; -/** Every spelling of the console dist-cache key carries this. */ -const CONSOLE_KEY_MARKER = '-console-dist-'; - -/** - * The `console` entries that are GUARDS rather than build inputs: code every - * run of the job executes, cache hit or miss, so a change to one is judged even - * when the dist is restored. Every other entry must move the dist key. A new - * entry lands in neither place by default: it reds as unclassified until - * someone decides which it is. - */ -export const CONSOLE_GUARDS = Object.freeze({ - '.github/workflows/ci.yml': 'the job definition itself: its steps run on a hit and on a miss', - 'scripts/check-console-sha.mjs': 'run by `pnpm check:console-sha` on every run, hit or miss', - 'scripts/check-console-injection.mjs': 'run by `pnpm check:console-injection --require-stamp` on every run, hit or miss', -}); - -/** The quoted arguments of the ONE `hashFiles(...)` a key spells, or null. */ -export function hashFilesInputs(key) { - const calls = [...String(key).matchAll(/hashFiles\(([^)]*)\)/g)]; - if (calls.length !== 1) return null; - const args = calls[0][1].split(',').map((a) => a.trim()); - if (args.length === 0 || args.some((a) => !/^'[^']+'$/.test(a))) return null; - return args.map((a) => a.slice(1, -1)); -} - -/** - * Does a path select the console job? Only answerable when every entry is a - * literal path, which `judgeConsole` requires; `null` otherwise, so a caller - * can never read a pattern list as a membership test. - */ -export function consoleSelects(path, entries) { - if (entries.some((e) => WILDCARD.test(e))) return null; - return entries.includes(path); -} - -/** - * The verdict on the console selection and the key it must move. `{ refusal }` - * for every state in which the subject was not read; otherwise the findings, - * each list empty on a clean tree. - */ -export function judgeConsole(source) { - const read = readFilterLists(source); - if (read.refusal) return read; - const { jobs, filters } = read; - - const entries = filters[CONSOLE_FILTER]; - if (!entries) return { refusal: `${CI_WORKFLOW}'s \`filters:\` input declares no \`${CONSOLE_FILTER}:\` filter.` }; - if (entries.length === 0) return { refusal: `${CI_WORKFLOW}'s \`${CONSOLE_FILTER}:\` filter is empty.` }; - - const job = jobs[CONSOLE_JOB]; - if (!job) return { refusal: `${CI_WORKFLOW} has no \`${CONSOLE_JOB}\` job -- the console gate has moved.` }; - const condition = typeof job.if === 'string' ? job.if : ''; - if (!condition.includes(`needs.filter.outputs.${CONSOLE_FILTER}`)) { - return { - refusal: - `${CI_WORKFLOW}'s \`${CONSOLE_JOB}\` job no longer names \`${CONSOLE_FILTER}\` in its \`if:\`, so that ` + - `filter does not schedule it any more and parity against it means nothing.\n if: ${condition || '(absent)'}`, - }; - } - - // Every place the job spells the key: the restore and save steps' `with.key`, - // and any env value that carries it (the remedy text the injection check prints). - const steps = Array.isArray(job.steps) ? job.steps : []; - const spellings = []; - for (const step of steps) { - const uses = String(step?.uses ?? ''); - const key = step?.with?.key; - if (typeof key === 'string' && key.includes(CONSOLE_KEY_MARKER)) spellings.push({ where: `${uses || step?.name} key`, key, uses }); - for (const [name, value] of Object.entries(step?.env ?? {})) { - if (typeof value === 'string' && value.includes(CONSOLE_KEY_MARKER)) spellings.push({ where: `env ${name}`, key: value, uses: '' }); - } - } - const restores = spellings.filter((s) => s.uses.startsWith('actions/cache/restore')); - const saves = spellings.filter((s) => s.uses.startsWith('actions/cache/save')); - if (restores.length !== 1 || saves.length !== 1) { - return { - refusal: - `${CI_WORKFLOW}'s \`${CONSOLE_JOB}\` job spells the console dist key in ${restores.length} restore and ` + - `${saves.length} save step(s); this gate reads exactly one of each. The cache has moved or been split.`, - }; - } - const keyInputs = hashFilesInputs(restores[0].key); - if (!keyInputs) { - return { - refusal: - `${CI_WORKFLOW}'s console dist key does not spell exactly one \`hashFiles(...)\` of quoted paths:\n ${restores[0].key}`, - }; - } - - const mismatched = spellings.filter((s) => s.key !== restores[0].key).map((s) => s.where); - const patterns = entries.filter((e) => WILDCARD.test(e)); - const unselected = keyInputs.filter((p) => !entries.includes(p)); - const unclassified = entries.filter((e) => !keyInputs.includes(e) && !Object.hasOwn(CONSOLE_GUARDS, e)); - const staleGuards = Object.keys(CONSOLE_GUARDS).filter((g) => !entries.includes(g)); - const hashedGuards = Object.keys(CONSOLE_GUARDS).filter((g) => keyInputs.includes(g)); - - return { entries, keyInputs, spellings: spellings.length, mismatched, patterns, unselected, unclassified, staleGuards, hashedGuards }; -} - -function reportConsole(verdict) { - if (verdict.refusal) { - console.error(`FAIL: check-ci-filter-parity could not judge the console selection.\n\n - ${verdict.refusal}\n`); - return 1; - } - const problems = []; - const lines = (xs) => xs.map((x) => ` ${x}`).join('\n'); - if (verdict.patterns.length > 0) { - problems.push( - `the \`${CONSOLE_FILTER}:\` filter carries pattern entr(ies):\n${lines(verdict.patterns)}\n` + - ' Name files. A pattern under packages/spec widens the console gate to every spec change, and a\n' + - ' list of literal paths is what lets this gate say which paths select the job.', - ); - } - if (verdict.mismatched.length > 0) { - problems.push( - `the \`${CONSOLE_JOB}\` job spells the console dist key differently at: ${verdict.mismatched.join(', ')}\n` + - ' The restore, the save and the remedy text must name ONE key, or a run saves an entry no run restores.', - ); - } - if (verdict.unselected.length > 0) { - problems.push( - `the console dist key hashes path(s) the \`${CONSOLE_FILTER}:\` filter does not name:\n${lines(verdict.unselected)}\n` + - ' A change to one moves the key without starting the job, so its first build is the merge queue\'s,\n' + - ` where a check outside the required set cannot stop a merge. Add each to \`${CONSOLE_FILTER}:\`.`, - ); - } - if (verdict.unclassified.length > 0) { - problems.push( - `the \`${CONSOLE_FILTER}:\` filter names path(s) the dist key does not hash and CONSOLE_GUARDS does not declare:\n` + - `${lines(verdict.unclassified)}\n` + - ' A head that moves one starts the job, restores the dist built before the change and skips the build\n' + - ' step, so the run is green without judging it. A BUILD INPUT goes into the key\'s hashFiles (all of its\n' + - ' spellings); a GUARD that every run executes goes into CONSOLE_GUARDS in this file, with the step.', - ); - } - if (verdict.staleGuards.length > 0) { - problems.push( - `CONSOLE_GUARDS declares path(s) the \`${CONSOLE_FILTER}:\` filter no longer names:\n${lines(verdict.staleGuards)}\n` + - ' Delete the declaration, or restore the entry if the guard still runs.', - ); - } - if (verdict.hashedGuards.length > 0) { - problems.push( - `CONSOLE_GUARDS declares path(s) the dist key also hashes:\n${lines(verdict.hashedGuards)}\n` + - ' A guard runs on a cache hit, so hashing it only buys a rebuild per change. Pick one kind.', - ); - } - if (problems.length > 0) { - console.error('FAIL: ci.yml\'s `console` filter and the console dist key are out of step.\n'); - for (const p of problems) console.error(` - ${p}\n`); - return 1; - } - console.log( - `OK: all ${verdict.entries.length} \`${CONSOLE_FILTER}\` entr(ies) are literal paths; ` + - `${verdict.keyInputs.length} are build inputs the console dist key hashes and ` + - `${verdict.entries.length - verdict.keyInputs.length} are declared guards; the key is spelled one way ` + - `in all ${verdict.spellings} place(s) the \`${CONSOLE_JOB}\` job uses it.`, - ); - return 0; -} - // ── The third subject: Build Core and the build inputs turbo.json declares (#21202) ── /** The job every build input must schedule, by id, and the filter its `if:` reads. */ @@ -1006,11 +815,10 @@ export function main(root = REPO_ROOT, table = CROSS_PACKAGE_TEST_INPUTS) { } // Every subject is judged and every one reports, so one red never hides another. const crosspkg = report(judge(source, table)); - const consoleCode = reportConsole(judgeConsole(source)); const buildCode = reportBuildInputs( judgeBuildInputs(source, readOrNull(root, TURBO_CONFIG), readOrNull(root, ROOT_MANIFEST), closureIn(root)), ); - return crosspkg === 0 && consoleCode === 0 && buildCode === 0 ? 0 : 1; + return crosspkg === 0 && buildCode === 0 ? 0 : 1; } /** A root file's text, or null when it cannot be read -- the judge refuses on null by name. */ @@ -1038,17 +846,6 @@ function list(root = REPO_ROOT, table = CROSS_PACKAGE_TEST_INPUTS) { } console.log(`\n${seen.size} unique glob(s), ${verdict.uncovered.length} uncovered declaration(s).`); - const con = judgeConsole(readFileSync(join(root, CI_WORKFLOW), 'utf8')); - if (con.refusal) { - console.error(`FAIL: ${con.refusal}`); - return 1; - } - console.log(`\n${CONSOLE_FILTER}: ${JSON.stringify(con.entries)}`); - for (const entry of con.entries) { - const kind = con.keyInputs.includes(entry) ? 'build input (hashed into the dist key)' : Object.hasOwn(CONSOLE_GUARDS, entry) ? `guard: ${CONSOLE_GUARDS[entry]}` : 'UNCLASSIFIED'; - console.log(`${kind === 'UNCLASSIFIED' ? 'FAIL' : 'ok '} ${entry} ${kind}`); - } - const build = judgeBuildInputs( readFileSync(join(root, CI_WORKFLOW), 'utf8'), readOrNull(root, TURBO_CONFIG), @@ -1070,7 +867,6 @@ function list(root = REPO_ROOT, table = CROSS_PACKAGE_TEST_INPUTS) { for (const entry of build.stale) console.log(`FAIL ${entry} stale: covers no declared build input and no script a ${BUILD_JOB} step runs`); console.log(`left out (excluded by \`${BUILD_COMMAND}\`): ${build.excludedTasks.join(', ') || 'none'}`); return verdict.uncovered.length > 0 || - con.unclassified.length > 0 || build.uncovered.length > 0 || build.guardsUncovered.length > 0 || build.stale.length > 0 @@ -1434,111 +1230,8 @@ export async function selfTest() { assert(lint.includes(`node ${SELF} --self-test`), 'wiring: lint.yml runs the --self-test leg too'); } - // ── (8) the `console` selection and the dist key it must move (#20765) ─── - battery('(8) the `console` selection and the dist key it must move'); + // The checked-in ci.yml, which the real-tree cases of (9) and (10) read. const realSource = readFileSync(join(REPO_ROOT, CI_WORKFLOW), 'utf8'); - const realConsole = judgeConsole(realSource); - const findingsOf = (v) => - ['mismatched', 'patterns', 'unselected', 'unclassified', 'staleGuards', 'hashedGuards'].flatMap((k) => v[k] ?? ['(no verdict)']); - assert(!realConsole.refusal, `the checked-in ci.yml's console selection is readable -- ${realConsole.refusal ?? ''}`); - assert(findingsOf(realConsole).length === 0, `the checked-in console filter and dist key are in step -- ${findingsOf(realConsole).join(', ')}`); - // Triage's pins, over the real tree: the spec's entry layout selects the job - // AND moves the key, so the head judges the change instead of replaying a dist - // built before it; an ordinary spec source file does neither. - for (const layout of ['packages/spec/package.json', 'packages/spec/tsup.config.ts']) { - assert( - consoleSelects(layout, realConsole.entries ?? []) === true && (realConsole.keyInputs ?? []).includes(layout), - `a head touching only ${layout} selects Console Pin Gate and moves its dist key`, - ); - } - const control = 'packages/spec/src/ui/view.zod.ts'; - assert( - existsSync(join(REPO_ROOT, control)) && consoleSelects(control, realConsole.entries ?? []) === false, - `the control: a head touching only ${control} (a tracked spec source file) does NOT select Console Pin Gate`, - ); - assert( - !(realConsole.entries ?? []).some((e) => e.startsWith('packages/spec/src/')), - 'no console entry reaches into packages/spec/src -- the entry layout selects the job, the spec content does not', - ); - - // Synthetic trees. The default is clean, so every red below is the named drift. - const guardEntries = Object.keys(CONSOLE_GUARDS); - const keyOf = (paths) => `\${{ runner.os }}-console-dist-\${{ hashFiles(${paths.map((p) => `'${p}'`).join(', ')}) }}`; - const consoleFixture = ({ entries, key, saveKey, envKey, condition, withSave = true } = {}) => { - const k = key ?? keyOf(['.objectui-sha', 'scripts/build-console.sh']); - const list = (xs) => xs.map((e) => ` - '${e}'`).join('\n'); - return [ - 'name: CI', - 'jobs:', - ' filter:', - ' steps:', - ' - uses: dorny/paths-filter@v4', - ' id: changes', - ' with:', - ' filters: |', - ' core:', - list(['packages/**']), - ' console:', - list(entries ?? ['.objectui-sha', 'scripts/build-console.sh', ...guardEntries]), - ' console-pin:', - ` if: ${JSON.stringify(condition ?? "${{ !cancelled() && needs.filter.outputs.console != 'false' }}")}`, - ' steps:', - ' - uses: actions/cache/restore@v6', - ' with:', - ' path: packages/console/dist', - ` key: ${k}`, - ' - run: pnpm check:console-injection --require-stamp', - ' env:', - ` CONSOLE_DIST_CACHE_KEY: ${envKey ?? k}`, - ...(withSave - ? [' - uses: actions/cache/save@v6', ' with:', ' path: packages/console/dist', ` key: ${saveKey ?? k}`] - : []), - ].join('\n'); - }; - const clean = judgeConsole(consoleFixture()); - assert(!clean.refusal && findingsOf(clean).length === 0, `positive control: the default synthetic tree is clean -- ${clean.refusal ?? findingsOf(clean).join(', ')}`); - - const specInFilterOnly = judgeConsole( - consoleFixture({ entries: ['.objectui-sha', 'scripts/build-console.sh', 'packages/spec/package.json', ...guardEntries] }), - ); - assert( - (specInFilterOnly.unclassified ?? []).join(',') === 'packages/spec/package.json', - 'THE HOLE: a spec path the filter names but the key does not hash is reported -- the head would replay a pre-change dist', - ); - const keyOnly = judgeConsole(consoleFixture({ key: keyOf(['.objectui-sha', 'scripts/build-console.sh', 'packages/spec/package.json']) })); - assert( - (keyOnly.unselected ?? []).join(',') === 'packages/spec/package.json', - 'the other hole: a path the key hashes but the filter does not name is reported -- its first build would be the queue\'s', - ); - const subtree = judgeConsole(consoleFixture({ entries: ['.objectui-sha', 'scripts/build-console.sh', 'packages/spec/**', ...guardEntries] })); - assert((subtree.patterns ?? []).join(',') === 'packages/spec/**', 'a pattern entry is reported -- the filter must not become all of spec'); - assert(consoleSelects('packages/spec/src/x.zod.ts', ['packages/spec/**']) === null, '-- and a pattern list is never read as a membership test'); - const splitKey = judgeConsole(consoleFixture({ saveKey: keyOf(['.objectui-sha']) })); - assert( - (splitKey.mismatched ?? []).length === 1 && /cache\/save/.test(splitKey.mismatched[0]), - 'a save step spelling a different key than the restore is reported, naming the save', - ); - const staleGuard = judgeConsole( - consoleFixture({ entries: ['.objectui-sha', 'scripts/build-console.sh', ...guardEntries.filter((g) => g !== CI_WORKFLOW)] }), - ); - assert((staleGuard.staleGuards ?? []).join(',') === CI_WORKFLOW, 'a declared guard the filter no longer names is reported stale'); - const hashedGuard = judgeConsole(consoleFixture({ key: keyOf(['.objectui-sha', 'scripts/build-console.sh', CI_WORKFLOW]) })); - assert((hashedGuard.hashedGuards ?? []).join(',') === CI_WORKFLOW, 'a declared guard the key also hashes is reported'); - - assert( - /declares no \`console:\` filter/.test(judgeConsole(consoleFixture().replace(/ console:\n( - '[^']*'\n?)+/, '')).refusal ?? ''), - 'a filters input with no `console:` list => REFUSAL, not a clean zero', - ); - assert( - /no longer names \`console\`/.test(judgeConsole(consoleFixture({ condition: "${{ !cancelled() }}" })).refusal ?? ''), - 'the console job dropping the filter from its `if:` => REFUSAL', - ); - assert(/exactly one of each/.test(judgeConsole(consoleFixture({ withSave: false })).refusal ?? ''), 'a job with no save step => REFUSAL'); - assert( - hashFilesInputs("${{ runner.os }}-x-${{ hashFiles(env.PATHS) }}") === null && - /quoted paths/.test(judgeConsole(consoleFixture({ key: "${{ runner.os }}-console-dist-${{ hashFiles(env.PATHS) }}" })).refusal ?? ''), - 'a key whose hashFiles arguments are not quoted paths => REFUSAL: the gate cannot say what it hashes', - ); // A scratch root carrying the given ci.yml beside the REAL turbo.json and // root manifest, so the only drift `main()` can see is the one injected: a @@ -1564,15 +1257,6 @@ export async function selfTest() { } }; - // The report path, over the real ci.yml with one drift injected: the spec's - // tsup entry list dropped from the filter while the key still hashes it. - const drifted = realSource.replace(" - 'packages/spec/tsup.config.ts'\n", ''); - assert(drifted !== realSource, 'the report-path fixture found its anchor in the checked-in ci.yml'); - assert( - inScratchTree(drifted, (root) => quietly(() => main(root))) === 1, - 'main() returns 1 over a ci.yml whose console filter lost a hashed path -- the report path, not only `judgeConsole`', - ); - // ── (9) Build Core and the build inputs turbo.json declares (#21202) ──── battery('(9) Build Core and the build inputs turbo.json declares'); const BUILD_IF = "${{ !cancelled() && needs.filter.outputs.core != 'false' }}"; @@ -1967,9 +1651,7 @@ export async function selfTest() { `\`core\`, one covered only by \`crosspkg\` and one covered by neither judged separately in one table, the ` + `stale-entry direction, seven refusals over subjects that could not be read, the checked-in ci.yml, the ` + `pre-#10015 rollback uncovering the ten it fixed plus #10848's one plus #10178's two plus #12201's one plus #12924's one plus #14561's one plus #14824's three plus #15818's two plus #18650's one, minus the three under the \`skills/**\` build input \`core\` now covers, ` + - `the CI wiring read out of lint.yml, and the \`console\` selection: the spec's entry layout selecting Console Pin ` + - `Gate and moving its dist key while a spec source file does neither, each way the filter and the key can drift ` + - `observed red, and the report path red over the checked-in ci.yml with one hashed path dropped from the filter; ` + + `and the CI wiring read out of lint.yml; ` + `and Build Core's build inputs: a build input dropped from \`core\` observed red by path and declaring task, ` + `turbo.json itself, a globalDependencies entry and a new-root input each required, test-task and negated inputs ` + `and the excluded docs build left out until the build script stops excluding it, a leftover literal entry, ` + diff --git a/scripts/check-release-section-coverage.mjs b/scripts/check-release-section-coverage.mjs index fbded98a213..75b71a6ec9f 100644 --- a/scripts/check-release-section-coverage.mjs +++ b/scripts/check-release-section-coverage.mjs @@ -75,11 +75,11 @@ // whichever PR happens to run CI next". An uncovered minor is a fact about the // release history, not about whichever PR runs CI next. Same shape, same answer. // -// Loud-but-non-blocking is `prerelease-pin-watch.yml`'s mechanism, reused here: +// Loud-but-non-blocking was `prerelease-pin-watch.yml`'s mechanism (retired, #25697), reused here: // a `::warning::` annotation plus a step-summary table, job GREEN. `--strict` // promotes findings to exit 1 for a caller that OWNS the remedy (a standing // patrol, or someone editing the release pages) — the same escape hatch that -// workflow's own `--strict` provides. +// workflow's own `--strict` provided. // // ## What IS fatal: the instrument // @@ -1184,7 +1184,7 @@ function renderOk(checked, minorCount) { } /** - * Loud-but-non-blocking, the `prerelease-pin-watch.yml` mechanism: a `::warning::` + * Loud-but-non-blocking, the retired `prerelease-pin-watch.yml` mechanism: a `::warning::` * annotation plus a step-summary table, job GREEN. Silent outside Actions. * * @param {string[]} findings diff --git a/scripts/check-required-contexts.mjs b/scripts/check-required-contexts.mjs index 691405aac24..8ab8b34dcc1 100644 --- a/scripts/check-required-contexts.mjs +++ b/scripts/check-required-contexts.mjs @@ -334,6 +334,11 @@ const UNATTRIBUTED_BATTERY = '(no battery open)'; * is right. Budgeting around it would only arm the trap for the next author * who legitimately names the live job. * + * `Console Pin Gate` IS dead since #25697 deleted its job with the objectui pin + * gates. It still takes no RETIRED_CONTEXT_NAMES row: it never sat in any + * reading of the required set, and the instruction files no longer name it as + * a live check (`docs/releases-maintenance.md` keeps it in the past tense). + * * ⚠️ The cost of the drop, stated rather than discovered later: these two * `name:` literals are now pinned by NOTHING, while prose elsewhere still refers * to the jobs by name — `docs/releases-maintenance.md`, diff --git a/scripts/check-self-test-workflow-commands.mjs b/scripts/check-self-test-workflow-commands.mjs index c0041b48d34..13f13074355 100644 --- a/scripts/check-self-test-workflow-commands.mjs +++ b/scripts/check-self-test-workflow-commands.mjs @@ -35,11 +35,11 @@ * safe by indenting it or by burying it in prose. * * **2. The `::...::` form is parsed only at LINE START.** Measured against the - * same population: `scripts/check-prerelease-pin-watch.mjs --self-test` prints - * two lines carrying `::error::` and `::warning::` mid-sentence on every PR, - * and its check run carries zero annotations. Those two lines are inert where - * they sit and one re-indentation away from not being; this gate reddens at the - * moment they move, which is the moment they start lying. + * same population: `scripts/check-prerelease-pin-watch.mjs --self-test` (since + * retired, #25697) printed two lines carrying `::error::` and `::warning::` + * mid-sentence on every PR, and its check run carried zero annotations. Such a + * line is inert where it sits and one re-indentation away from not being; this + * gate reddens at the moment one moves, which is the moment it starts lying. * * ⚠️ Neither fact is inferable from a local run — the transformation happens on * a runner and nowhere else. That is exactly why the property needs a gate diff --git a/scripts/check-vendor-version-stamps.mjs b/scripts/check-vendor-version-stamps.mjs index a205b1070a4..491f0fda2be 100644 --- a/scripts/check-vendor-version-stamps.mjs +++ b/scripts/check-vendor-version-stamps.mjs @@ -62,10 +62,11 @@ // written, true now, true after 1.9.0. Forcing them to the pin would // fabricate that many measurements. (Those figures are a dated reading, not // a live one: run `--census` for today's.) -// - Some sites are not attestations at all. It is SELF-TEST FIXTURE DATA -// inside `check-prerelease-pin-watch.mjs` — a synthetic npm registry payload -// whose `1.7.1` is an arbitrary input proving the watcher notices a stable -// release. A pin-equality sweep would corrupt a gate's own test input. +// - Some sites are not attestations at all. One was SELF-TEST FIXTURE DATA +// inside `check-prerelease-pin-watch.mjs` (since retired) — a synthetic +// npm registry payload whose `1.7.1` was an arbitrary input proving the +// watcher noticed a stable release. A pin-equality sweep would corrupt a +// gate's own test input. // // So the rule is about PHRASING, not about version equality: // @@ -376,8 +377,8 @@ export const ANCHOR_PATTERN = /\b20\d\d-\d\d-\d\d\b|#\d{3,6}\b/; * Gate self-tests build fake registry payloads and fake prose to drive their own * assertions; those numbers are arbitrary test data, and holding them to the * resolved pin would corrupt another gate's fixture. #13940 measured one such - * site inside `check-prerelease-pin-watch.mjs` — its `1.7.1` proves the watcher - * notices a stable release and means nothing about this tree. + * site inside `check-prerelease-pin-watch.mjs` (since retired) — its `1.7.1` + * proved the watcher noticed a stable release and meant nothing about this tree. * * It is an INLINE declaration rather than a central exemption list on purpose: a * central list rots away from the thing it describes, while this sits on the diff --git a/scripts/ci/scheduled-full-run.mjs b/scripts/ci/scheduled-full-run.mjs index c2a98f20488..7d46e326236 100644 --- a/scripts/ci/scheduled-full-run.mjs +++ b/scripts/ci/scheduled-full-run.mjs @@ -11,9 +11,8 @@ // Half 3 (#22998) is the READER the full battery gained when PR and queue runs // became the fast set: release.yml's `full-battery` job runs `--release-gate` // before the `release` environment approval, and the publish lane refuses -// unless the newest full-battery runs of `CI`, `Lint & Type Check` and -// `Spec Shape Gate` at or before the version commit concluded `success`. See -// HALF 3 below. +// unless the newest full-battery runs of `CI` and `Lint & Type Check` at or +// before the version commit concluded `success`. See HALF 3 below. // // ## Why one file holds the first two halves // @@ -753,14 +752,14 @@ export function renderBody({ // --------------------------------------------------------------------------- // // A PR or queue build carries the FAST SET since #22998 (ci.yml's THE TWO -// SPEEDS); the dogfood, temporal and console-pin legs run only on `main`'s +// SPEEDS); the dogfood and temporal legs run only on `main`'s // push, hourly schedule and dispatch runs. "Tested before a release" is // therefore a statement about those runs, and this half makes it a gate: // release.yml's `full-battery` job, which `publish` needs, so a refusal here // means no deployment is queued at the `release` environment at all. // -// THE SELECTION RULE, per workflow (`CI`, `Lint & Type Check`, `Spec Shape -// Gate`), pinned below: +// THE SELECTION RULE, per workflow (`CI`, `Lint & Type Check`), pinned +// below: // // 1. candidates are runs whose event is `schedule` or `push`, on `main`, // `completed`, whose head is the release commit or one of its first-parent @@ -788,12 +787,11 @@ export function renderBody({ export const RELEASE_GATE_WORKFLOWS = Object.freeze([ Object.freeze({ file: 'ci.yml', name: 'CI' }), Object.freeze({ file: 'lint.yml', name: 'Lint & Type Check' }), - // The spec shape gate (ruling B, comment 6145029395 on #23238): objectui - // `main` compiled against the spec built from the commit, hourly on `main`. - // It has no `push` trigger, so only its `schedule` runs qualify here; a run - // whose every diagnostic is a registered retirement concluded `success` and - // counts as green, an unregistered break concluded `failure` and refuses. - Object.freeze({ file: 'spec-shape-gate.yml', name: 'Spec Shape Gate' }), + // `Spec Shape Gate` (spec-shape-gate.yml) was the third row: objectui `main` + // compiled hourly against the spec built from the commit. It retired with + // the objectui import (objectstack#25697): the Console is a workspace under + // `ui/`, so ci.yml's `Type Check UI` compiles it against the spec in the PR + // that moves the shape, and CI's own runs carry that verdict here. ]); // The events that carry the full battery on `main`, and the one conclusion that @@ -1853,10 +1851,10 @@ async function selfTest() { const recorded = JSON.parse( readFileSync(fileURLToPath(new URL('../fixtures/release-full-battery/runs-2026-10-12.json', import.meta.url)), 'utf8') ); - // The recording predates the Spec Shape Gate and holds runs of exactly the - // two workflows it names. The cases below judge those two, EXPLICITLY -- the - // roster, not the fixture, decides what a release reads -- and the two cases - // after them pin that the default roster now reads the third. + // The recording holds runs of exactly the two workflows the roster names. + // The cases below judge those two, EXPLICITLY -- the roster, not the + // fixture, decides what a release reads -- and the firing control after the + // first pins that the default roster reads both of them and nothing else. const recordedWorkflows = RELEASE_GATE_WORKFLOWS.filter(({ file }) => Object.hasOwn(recorded.runs, file)); const recordedVerdict = (fromIndex) => releaseGateVerdict({ history: recorded.firstParent.slice(fromIndex), runsByWorkflow: recorded.runs, workflows: recordedWorkflows }); @@ -1866,13 +1864,18 @@ async function selfTest() { } }); await check(() => { - // FIRING CONTROL for the registration: over the SAME recording, the full - // roster refuses release commit 0524c1619b naming Spec Shape Gate alone -- - // the third workflow is read, and a workflow with no run is a refusal. - const v = releaseGateVerdict({ history: recorded.firstParent, runsByWorkflow: recorded.runs }); - if (v.ok || v.refusals.length !== 1 || !v.refusals[0].startsWith('Spec Shape Gate: no completed')) { - throw new Error(`recorded: the default roster must refuse for Spec Shape Gate alone, got ${v.refusals.join(' | ')}`); + // FIRING CONTROL for the roster: over the SAME recording with lint.yml's + // runs taken away, the default roster refuses release commit 0524c1619b + // naming Lint & Type Check alone -- the second workflow is read, and a + // workflow with no run is a refusal. With the recording whole it passes, + // so the default roster reads no workflow the recording lacks. + const { 'lint.yml': _lintRuns, ...withoutLint } = recorded.runs; + const v = releaseGateVerdict({ history: recorded.firstParent, runsByWorkflow: withoutLint }); + if (v.ok || v.refusals.length !== 1 || !v.refusals[0].startsWith('Lint & Type Check: no completed')) { + throw new Error(`recorded: the default roster must refuse for Lint & Type Check alone, got ${v.refusals.join(' | ')}`); } + const whole = releaseGateVerdict({ history: recorded.firstParent, runsByWorkflow: recorded.runs }); + if (!whole.ok) throw new Error(`recorded: the default roster must pass 0524c1619b on the whole recording, refused: ${whole.refusals.join(' | ')}`); }); await check(() => { // Release commit 0524c1619b: both workflows' nearest verdict is green. @@ -1936,14 +1939,12 @@ async function selfTest() { ...extra, }); const pick = (runs) => selectFullBatteryRun({ history: H, runs, workflowFile: 'ci.yml' })?.run.id ?? null; - const shapeGreen = [run(8, 0, 'success')]; - const both = (ciRuns, lintRuns, shapeRuns = shapeGreen) => + const both = (ciRuns, lintRuns) => releaseGateVerdict({ history: H, runsByWorkflow: { 'ci.yml': ciRuns, 'lint.yml': lintRuns.map((r) => ({ ...r, path: '.github/workflows/lint.yml' })), - 'spec-shape-gate.yml': shapeRuns.map((r) => ({ ...r, path: '.github/workflows/spec-shape-gate.yml' })), }, }); await check(() => { @@ -2008,29 +2009,29 @@ async function selfTest() { if (releaseGateVerdict({ history: [], runsByWorkflow: {} }).ok) throw new Error('synthetic: an empty history passed'); }); await check(() => { - // The third workflow: a red Spec Shape Gate refuses alone, with CI and Lint green. - const v = both([run(1, 0, 'success')], [run(2, 0, 'success')], [run(3, 0, 'failure')]); - if (v.ok || v.refusals.length !== 1 || !v.refusals[0].startsWith('Spec Shape Gate:') || !v.refusals[0].includes('failure')) { - throw new Error(`synthetic: a red Spec Shape Gate did not refuse alone (${v.refusals.join(' | ')})`); + // The other direction: a red CI refuses alone, with Lint green. + const v = both([run(1, 0, 'failure')], [run(2, 0, 'success')]); + if (v.ok || v.refusals.length !== 1 || !v.refusals[0].startsWith('CI:') || !v.refusals[0].includes('failure')) { + throw new Error(`synthetic: a red CI run did not refuse alone (${v.refusals.join(' | ')})`); } }); await check(() => { - // ...and no Spec Shape Gate run at all refuses too: an unread gate is not a green one. - const v = both([run(1, 0, 'success')], [run(2, 0, 'success')], []); - if (v.ok || v.refusals.length !== 1 || !v.refusals[0].startsWith('Spec Shape Gate: no completed')) { - throw new Error(`synthetic: a missing Spec Shape Gate run did not refuse naming it (${v.refusals.join(' | ')})`); + // ...and no Lint & Type Check run at all refuses too: an unread gate is not a green one. + const v = both([run(1, 0, 'success')], []); + if (v.ok || v.refusals.length !== 1 || !v.refusals[0].startsWith('Lint & Type Check: no completed')) { + throw new Error(`synthetic: a missing Lint & Type Check run did not refuse naming it (${v.refusals.join(' | ')})`); } }); await check(() => { - // ...while a `workflow_dispatch` run of it is not a full-battery run (same rule as the other two). - const v = both([run(1, 0, 'success')], [run(2, 0, 'success')], [run(3, 0, 'failure', { event: 'workflow_dispatch' }), run(4, 1, 'success')]); - if (!v.ok) throw new Error(`synthetic: a dispatch run of Spec Shape Gate was read as full battery (${v.refusals.join(' | ')})`); + // ...while a `workflow_dispatch` run of it is not a full-battery run (same rule as CI's). + const v = both([run(1, 0, 'success')], [run(3, 0, 'failure', { event: 'workflow_dispatch' }), run(4, 1, 'success')]); + if (!v.ok) throw new Error(`synthetic: a dispatch run of Lint & Type Check was read as full battery (${v.refusals.join(' | ')})`); }); battery('release gate: the read path retries a transient answer, inside one budget per run'); // The four pins the card names, driven through `runReleaseGate` -- the // refusal text and the exit included -- with no network and no wall-clock - // backoff: `fetchImpl` is a stub over the six listings (three workflows x two + // backoff: `fetchImpl` is a stub over the four listings (two workflows x two // events) and `sleep` records the backoff it was asked for. const RETRY = BOARD_READ_RETRY; const listingUrl = (file, event, page = 1) => @@ -2104,7 +2105,7 @@ async function selfTest() { if (g.exit !== 0 || !g.lines.at(-1)?.startsWith('scheduled-full-run --release-gate: PASS') || g.verdict?.lines.length !== RELEASE_GATE_WORKFLOWS.length) { throw new Error(`read path: a 502 then a 200 must be measured and pass, got exit ${g.exit}: ${g.said}`); } - if (g.calls.length !== 7 || g.calls.filter((u) => u === listingUrl('lint.yml', 'schedule')).length !== 2 || g.slept.join() !== '500') { + if (g.calls.length !== 5 || g.calls.filter((u) => u === listingUrl('lint.yml', 'schedule')).length !== 2 || g.slept.join() !== '500') { throw new Error(`read path: expected one retry after one 500 ms backoff, got ${g.calls.length} requests, slept ${g.slept.join(',') || 'never'}`); } }); @@ -2124,7 +2125,7 @@ async function selfTest() { onLint(at) && at.n === 1 ? { body: { workflow_runs: [] }, bodyThrows: connectionFailure('UND_ERR_SOCKET') } : null ); for (const [label, g] of [['a connection reset', reset], ['a socket closed mid-body', cut]]) { - if (g.exit !== 0 || g.calls.length !== 7 || g.slept.join() !== '500') { + if (g.exit !== 0 || g.calls.length !== 5 || g.slept.join() !== '500') { throw new Error(`read path: ${label}, then a 200, must be measured and pass, got exit ${g.exit} after ${g.calls.length} requests: ${g.said}`); } } @@ -2138,7 +2139,7 @@ async function selfTest() { if (at.page === 1) return { body: { workflow_runs: filler } }; return at.n === 1 ? { status: 503 } : null; }); - if (g.exit !== 0 || g.calls.filter((u) => u === listingUrl('ci.yml', 'schedule', 2)).length !== 2 || g.calls.length !== 8) { + if (g.exit !== 0 || g.calls.filter((u) => u === listingUrl('ci.yml', 'schedule', 2)).length !== 2 || g.calls.length !== 6) { throw new Error(`read path: a transient 503 on page 2 must be retried and measured, got exit ${g.exit} after ${g.calls.length} requests: ${g.said}`); } }); @@ -2164,7 +2165,7 @@ async function selfTest() { }); await check(async () => { // ⛔ An unread battery is never a green one: a persistent transient answer - // on ANY of the six listings refuses NOT MEASURED, exit 3. + // on ANY of the four listings refuses NOT MEASURED, exit 3. const persistent = [ ['HTTP 500', { status: 500 }], ['HTTP 502', { status: 502 }], @@ -2228,22 +2229,24 @@ async function selfTest() { // PIN 4 -- ONE budget per gate run, shared across every workflow x event x // page it reads; never one per `readRuns` call. await check(async () => { - // ⭐ Every listing answers 502 once, then 200. Six reads want six retries; - // the run allows four, so the FIFTH listing's first 502 is final. - const g = await readPathGate((at) => (at.n === 1 ? { status: 502 } : null)); + // ⭐ Every listing answers 502 twice, then 200. Four reads want eight + // retries; the run allows four, which the first two listings spend (two + // each, inside their three attempts), so the THIRD listing's first 502 is + // final. + const g = await readPathGate((at) => (at.n <= 2 ? { status: 502 } : null)); const want = `-> HTTP 502 after 1 attempt (this run's budget of ${RETRY.retriesPerRun} retries is spent)`; - if (g.exit !== EXIT_PREREQUISITE_NOT_MET || g.slept.length !== RETRY.retriesPerRun || g.calls.length !== 2 * RETRY.retriesPerRun + 1) { + if (g.exit !== EXIT_PREREQUISITE_NOT_MET || g.slept.length !== RETRY.retriesPerRun || g.calls.length !== RETRY.retriesPerRun + 3) { throw new Error(`read path: a flapping API must spend the run's ${RETRY.retriesPerRun} retries and then refuse, got exit ${g.exit} after ${g.calls.length} requests and ${g.slept.length} backoff(s): ${g.said}`); } - if (!g.lines[0]?.startsWith(`${NOT_MEASURED}GET ${listingUrl('spec-shape-gate.yml', 'schedule')} ${want}`)) { + if (!g.lines[0]?.startsWith(`${NOT_MEASURED}GET ${listingUrl('lint.yml', 'schedule')} ${want}`)) { throw new Error(`read path: the refusal must name the listing whose 502 met a spent budget, got ${g.said}`); } }); await check(async () => { // ...and the budget is exactly that, not tighter: four listings that blip // once each are still measured. - const g = await readPathGate((at) => (at.n === 1 && at.file !== 'spec-shape-gate.yml' ? { status: 502 } : null)); - if (g.exit !== 0 || g.slept.length !== RETRY.retriesPerRun || g.calls.length !== 6 + RETRY.retriesPerRun) { + const g = await readPathGate((at) => (at.n === 1 ? { status: 502 } : null)); + if (g.exit !== 0 || g.slept.length !== RETRY.retriesPerRun || g.calls.length !== 4 + RETRY.retriesPerRun) { throw new Error(`read path: ${RETRY.retriesPerRun} blips in one run must all be retried and measured, got exit ${g.exit} after ${g.calls.length} requests: ${g.said}`); } }); diff --git a/scripts/ci/spec-shape-gate.mjs b/scripts/ci/spec-shape-gate.mjs deleted file mode 100644 index 92686ddc4b0..00000000000 --- a/scripts/ci/spec-shape-gate.mjs +++ /dev/null @@ -1,1677 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * spec-shape-gate -- compile the downstream consumer (objectui at `main`) - * against `@objectstack/spec` built from THIS commit, name the objectui file - * and the objectstack commit when it does not compile, tell a REGISTERED - * retirement apart from a new break, and attribute a new break to the - * objectstack commit range since the last green run and the lane that owns it. - * - * node scripts/ci/spec-shape-gate.mjs inject --tarball --sha --upstream-checkout --downstream - * node scripts/ci/spec-shape-gate.mjs report --log --sha --status --downstream [--downstream-sha ] [--event ] [--registry ] - * node scripts/ci/spec-shape-gate.mjs --self-test # offline, no install, no network - * - * Exit: 0 = the step did what it says -- in `report`, a green compile, or a - * compile whose every diagnostic is a REGISTERED retirement (`known`) - * 1 = the compile failed with at least one diagnostic no registration - * covers -- a NEW shape break, attributed below - * 2 = the step COULD NOT BE PERFORMED (nothing to inject, a staged - * package that is not the spec, a failure no diagnostic names a file - * for, a registry this reader refuses). ⛔ Never a pass. A gate that - * cannot take its reading says so. - * - * ## The ruling this executes - * - * Ruling B on the decision card for the spec shape gate (comment 6145029395, - * batch 336 item 3, 2026-10-13): the gate MOVES to objectstack -- hourly on - * `main` with the two-speed full battery, read by the release gate, a red - * attributed to the lane that moved the shape; objectui drops its copy once two - * consecutive hourly runs here agree with objectui's. The MECHANISM of ruling C - * on #19011 is kept unchanged: consumer compilation is the ONE shape gate for - * the spec's public surface; what moved is where it runs and who owns a red. - * objectui deleted its copy in objectstack-ai/objectui PR #12596 (card - * objectstack-ai/objectui#12352), so this gate is the only one. - * - * This file is the objectstack port of objectui's `scripts/spec-main-shape-gate.mjs`, - * which that PR deleted. Its header carries the measurements behind the - * virtual-store injection, the hardlink trap, the dependency re-pointing and - * the turbo-cache false green; they are not restated here, and the mechanism is - * unchanged. Read them at objectui commit `7d0c174ba8383d08b8dd1d4f7d7845009f1bf2fd`, - * the first parent of that PR's merge commit `c6a42be392` and the last objectui - * `main` commit that has the file (`git show :scripts/spec-main-shape-gate.mjs` - * in an objectui clone). Three things are different, each because the - * direction reversed: - * - * 1. THE SUBJECT IS THIS COMMIT. objectui built the spec from objectstack - * `main` and compiled itself; here the spec is built and packed from the - * objectstack checkout the workflow runs in, and the CLONE is the - * downstream. There is no artifact reuse: the spec is always built from - * the tree under test, which is the one thing this gate must never - * substitute. - * 2. A RETIREMENT IS REGISTERED, NOT RED. An intentional retirement (a key or - * value the spec withdrew under enforce-or-remove) breaks the downstream - * until its consumer card lands; before this card that red stood for - * days and hid the next accidental break behind it. The accepting seat - * registers the retirement in `scripts/ci/spec-shape-retirements.json` - * when it files the downstream card (cross-repo rule 3), and `report` - * then reads the matching diagnostics as `known (registered: )` and - * does not red. An UNREGISTERED break stays red. See THE REGISTRY below. - * 3. A RED IS ATTRIBUTED. `report` names the objectstack commits since this - * workflow's last green run on `main`, the pull request each landed in, - * and the `domain:*` lane of the card that pull request fixes -- the lane - * whose first work the red is under the two-speed rule 3 of #22998. A - * lane it cannot read is written `unattributed`, never guessed. - * - * ⭐ WHAT A RED MUST SAY is still the acceptance criterion: which objectui file - * fails against which objectstack commit. A red saying only "typecheck failed" - * does not satisfy the ruling, and a failure no diagnostic attributes to a file - * is exit 2, never a shape break and never a pass. - * - * ## THE REGISTRY -- `scripts/ci/spec-shape-retirements.json` - * - * One entry per retirement, written by the accepting seat when the downstream - * card is filed, deleted when that card closes (objectui caught up): - * - * { - * "retirements": [ - * { - * "retired": "view-level data source provider 'api'", - * "objectstack_pr": 23160, - * "objectstack_commit": "1912af47ba6e3b250f7c717fea0d56e181029b9c", - * "downstream_card": "objectstack-ai/objectui#N", - * "registered_at": "2026-10-13", - * "matches": [ - * { "file": "packages/react/src/hooks/useViewData.ts", "code": "TS2367", "message": "'\"api\"'" } - * ] - * } - * ] - * } - * - * A diagnostic is `known` when SOME entry has SOME pattern whose every declared - * field holds: `file` equals the diagnostic's repo-relative objectui path (or - * is a glob over it: `*` within a segment, `**` across segments), `code` equals - * its TS code, `message` is a substring of its message. Every pattern MUST - * name a `file` AND at least one of `code` / `message`: a file alone would - * register "anything in this file", which is the ignore-all switch this - * registry must never become, and no pattern may be empty. Every entry MUST - * carry all six keys above and nothing else; an entry this reader cannot read - * is exit 2 for the whole run -- a registry that cannot be read is not an - * empty one, and compiling on without it would report a registered retirement - * as a new break or, worse, read a malformed ignore as nothing to ignore. - * - * An entry that matched NOTHING in a run is listed in the summary as such: it - * is either a retirement objectui has already caught up with (prune the entry - * when its card closes) or a registration whose pattern never matched (fix it - * on the registration's own pull request, which this workflow runs on). - * - * ## THE ATTRIBUTION -- commit range and lane - * - * On a `schedule`, `workflow_dispatch` or `push` run of this workflow on `main` - * (`ATTRIBUTED_EVENTS`), a red reads this workflow's own completed runs on - * `main`, picks the newest `success` whose head is not this run's, and asks the - * compare API for the commits between that head and this one. For each commit - * it reads the pull request its title names (`(#N)`, the squash suffix), that - * pull's own `domain:*` labels, the cards its body closes (`Fixes #M` and the - * other closing keywords), and those cards' `domain:*` labels. The union is the - * commit's lane set; an empty union is `unattributed`. - * - * Failure modes, each printed in the summary rather than guessed around: no - * previous green run of this workflow on `main` (the first runs, or a red - * standing since the first run) -- no range can be named, and the summary says - * so and says why; a compare the API truncates (it stops at 250 commits) -- the - * range is marked truncated; a pull or a card the token cannot read -- that - * commit is `unattributed` with the HTTP status beside it. ⛔ None of these - * moves the verdict: the verdict is the compile, the attribution is the - * address a red is delivered to. A `pull_request` run has no range to name -- - * its subject is one merge commit -- and says so. - * - * The lookups are capped (`ATTRIBUTION_MAX_COMMITS`) because a red that stands - * for a day makes the range a day long; the cap is printed when reached. - * - * ## The marker name is objectui's, on purpose - * - * `inject` writes `MARKER_FILE` into every replaced copy, and objectui's - * `turbo.json` names that exact file in `globalDependencies` so an injected - * tree never shares a turbo hash with an installed one. The spelling therefore - * stays objectui's (`.spec-main-shape-gate.json`) for as long as objectui's - * turbo config names it; the workflow bypasses the cache with `TURBO_FORCE` - * regardless, so the marker is belt to that brace, not a dependency. - */ - -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { execFileSync } from 'node:child_process'; -import { createRequire } from 'node:module'; - -import { isEntrypoint } from '../invoked-as.mjs'; - -export const SPEC_PACKAGE_NAME = '@objectstack/spec'; - -/** The repository whose commit is under test; the one this file lives in. */ -export const UPSTREAM_REPO = 'objectstack-ai/objectstack'; - -/** The downstream consumer the workflow clones at `main`. */ -export const DOWNSTREAM_REPO = 'objectstack-ai/objectui'; - -/** The workflow that runs this gate, as a quoted repo-relative literal: a card touching it schedules this family. */ -export const WORKFLOW_FILE = '.github/workflows/spec-shape-gate.yml'; - -/** The registry of intentional retirements, likewise a quoted literal. */ -export const REGISTRY_FILE = 'scripts/ci/spec-shape-retirements.json'; - -/** The marker `inject` leaves inside every replaced copy (see the header for why the name is objectui's). */ -export const MARKER_FILE = '.spec-main-shape-gate.json'; - -/** The events on `main` whose red is attributed to a commit range. */ -export const ATTRIBUTED_EVENTS = Object.freeze(['schedule', 'workflow_dispatch', 'push']); - -/** The cap on commits the attribution reads pull requests for. */ -export const ATTRIBUTION_MAX_COMMITS = 40; - -/** GitHub records at most this many error annotations per step; the summary carries the rest. */ -export const ANNOTATION_CAP = 10; - -/* ── failure ─────────────────────────────────────────────────────────────── */ - -function fail(message) { - process.stderr.write(`spec-shape-gate: ${message}\n`); - process.exit(2); -} - -/* ── the downstream workspace, read from the file pnpm reads ─────────────── */ - -/** - * The `packages:` globs of a `pnpm-workspace.yaml`, read narrowly: `- 'dir/*'` - * and `- 'dir'` entries only. Anything else -- a negation, a `**`, a glob inside - * a segment -- THROWS, because an importer the reader silently drops is one - * whose copy is never re-pointed, whose consumer link is never verified, and - * whose diagnostics are never attributed to a directory. - */ -export function parseWorkspaceGlobs(text) { - const globs = []; - let inPackages = false; - for (const raw of String(text).split('\n')) { - const line = raw.replace(/\s+#.*$/, '').replace(/\s+$/, ''); - if (line.length === 0 || /^\s*#/.test(line)) continue; - if (/^\S/.test(line)) { - inPackages = /^packages:\s*$/.test(line); - continue; - } - if (!inPackages) continue; - const entry = /^\s+-\s+(['"]?)([^'"]+)\1$/.exec(line); - if (!entry || !/^[\w.@-]+(?:\/[\w.@-]+)*(?:\/\*)?$/.test(entry[2])) { - throw new Error(`pnpm-workspace.yaml line "${raw.trim()}" is not a \`- 'dir'\` or \`- 'dir/*'\` entry`); - } - globs.push(entry[2]); - } - if (globs.length === 0) throw new Error('pnpm-workspace.yaml declares no `packages:` entries'); - return globs; -} - -/** The root and every workspace directory the downstream's `pnpm-workspace.yaml` names that holds a manifest. */ -export function workspaceImporterDirs(repoRoot) { - const globs = parseWorkspaceGlobs(fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf8')); - const dirs = new Set(['.']); - for (const glob of globs) { - if (glob.endsWith('/*')) { - const parent = glob.slice(0, -2); - const parentDir = path.join(repoRoot, parent); - if (!fs.existsSync(parentDir)) continue; - for (const child of fs.readdirSync(parentDir)) dirs.add(path.posix.join(parent, child)); - } else { - dirs.add(glob); - } - } - return [...dirs].filter((dir) => fs.existsSync(path.join(repoRoot, dir, 'package.json'))).sort(); -} - -function importerDirsOrFail(repoRoot) { - try { - return workspaceImporterDirs(repoRoot); - } catch (error) { - return fail( - `cannot enumerate the downstream's workspace importers under ${repoRoot}: ${error.message}. Refused ` + - `rather than guessed -- an importer left out keeps its own copy of a re-pointed dependency, and ` + - `the compile reads two copies without a word.`, - ); - } -} - -/** - * Map a turbo package name to its directory, by reading the workspace - * manifests. Derived, never tabulated: a hand-kept table is one package rename - * away from attributing a failure to the wrong file, which is worse than not - * attributing it at all. - */ -export function workspaceDirsByName(repoRoot) { - const byName = new Map([['//', '.']]); - for (const dir of workspaceImporterDirs(repoRoot)) { - if (dir === '.') continue; - try { - const { name } = JSON.parse(fs.readFileSync(path.join(repoRoot, dir, 'package.json'), 'utf8')); - if (name) byName.set(name, dir); - } catch { - // A manifest that does not parse is not this gate's subject; the file it - // would have named simply stays unmapped and is reported unmapped. - } - } - return byName; -} - -/* ── the installed spec, and who links to it ─────────────────────────────── */ - -/** - * The virtual-store directories holding an installed `@objectstack/spec`. - * Enumerated by PREFIX: pnpm encodes the peer resolution into the name - * (`@objectstack+spec@17.4.0_ai@...._zod@....`) and one install can hold more - * than one, so a computed name would move the consumers whose peer set - * happened to match a guess and leave the rest on the published spec. - */ -export function findInstalledSpecDirs(repoRoot) { - const virtualStore = path.join(repoRoot, 'node_modules', '.pnpm'); - if (!fs.existsSync(virtualStore)) return []; - return fs - .readdirSync(virtualStore) - .filter((entry) => entry.startsWith('@objectstack+spec@')) - .map((entry) => path.join(virtualStore, entry, 'node_modules', ...SPEC_PACKAGE_NAME.split('/'))) - .filter((dir) => fs.existsSync(path.join(dir, 'package.json'))) - .sort(); -} - -/** - * Every workspace importer whose own `node_modules` links to the spec, with - * the directory that link resolves to. This is the VERIFICATION population, - * taken from the consumers rather than from the injection: "every consumer - * that can see the spec now sees the injected one" is the property the - * typecheck depends on. - */ -export function findSpecConsumers(repoRoot) { - const consumers = []; - for (const dir of importerDirsOrFail(repoRoot)) { - const link = path.join(repoRoot, dir, 'node_modules', ...SPEC_PACKAGE_NAME.split('/')); - if (!fs.existsSync(link)) continue; - consumers.push({ workspace: dir, resolved: fs.realpathSync(link) }); - } - return consumers.sort((a, b) => a.workspace.localeCompare(b.workspace)); -} - -/** - * Every file an `exports` map names under a `types` condition. `tsc` reads - * exactly these, so they must exist in the staged package for a typecheck - * against it to mean anything: a spec built with declarations skipped still - * packs and still installs, and fails every consumer with TS2307. - */ -export function declaredTypesTargets(exportsMap) { - const targets = new Set(); - const walk = (node) => { - if (typeof node === 'string') return; - if (Array.isArray(node)) return node.forEach(walk); - if (!node || typeof node !== 'object') return; - for (const [condition, value] of Object.entries(node)) { - if (condition === 'types' && typeof value === 'string') targets.add(value); - else walk(value); - } - }; - walk(exportsMap ?? {}); - return [...targets].sort(); -} - -function extractTarball(tarball, intoDir) { - fs.mkdirSync(intoDir, { recursive: true }); - execFileSync('tar', ['-xzf', path.resolve(tarball), '-C', intoDir], { stdio: 'pipe' }); - const staged = path.join(intoDir, 'package'); - if (!fs.existsSync(staged)) { - fail( - `the tarball ${tarball} did not extract a \`package/\` directory. npm and pnpm both pack into one; ` + - `a tarball shaped otherwise is not a packed npm package.`, - ); - } - return staged; -} - -/* ── dependency ranges, deliberately narrow ──────────────────────────────── */ - -function parseRelease(version) { - const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(version).trim()); - if (!match) throw new Error(`"${version}" is not a plain X.Y.Z release, and this gate orders nothing else`); - return [Number(match[1]), Number(match[2]), Number(match[3])]; -} - -function compareReleases(left, right) { - for (let i = 0; i < 3; i += 1) { - if (left[i] !== right[i]) return left[i] < right[i] ? -1 : 1; - } - return 0; -} - -const RANGE_COMPARATOR = /^(\^|~|>=|<=|>|<|=)?(\d+\.\d+\.\d+)$/; - -function comparatorHolds(version, operator, bound) { - const order = compareReleases(version, bound); - if (operator === '=') return order === 0; - if (operator === '>') return order > 0; - if (operator === '>=') return order >= 0; - if (operator === '<') return order < 0; - if (operator === '<=') return order <= 0; - const ceiling = - operator === '~' - ? [bound[0], bound[1] + 1, 0] - : bound[0] > 0 - ? [bound[0] + 1, 0, 0] - : bound[1] > 0 - ? [0, bound[1] + 1, 0] - : [0, 0, bound[2] + 1]; - return order >= 0 && compareReleases(version, ceiling) < 0; -} - -/** - * Does the plain release `version` satisfy the dependency range `range`? - * Reads `X.Y.Z`, `=`, `^`, `~`, `>`, `>=`, `<`, `<=` on full bounds, - * space-separated comparator sets and `||` unions. Everything else THROWS, - * and the caller turns that into exit 2 naming the dependency: an unreadable - * range read as satisfied would leave this gate compiling against the wrong - * dependency without a word. Not the `semver` package, on purpose: it is not - * a dependency of this repository's root, and a hoisted transitive copy is a - * phantom dependency. - */ -export function satisfiesRange(version, range) { - const release = parseRelease(version); - const text = String(range ?? '').trim(); - if (text.length === 0) throw new Error('an empty range names nothing this gate can judge'); - const alternatives = text.split('||').map((alternative) => { - const comparators = alternative.trim().split(/\s+/).filter(Boolean); - if (comparators.length === 0) throw new Error(`range "${text}" has an empty alternative`); - return comparators.map((comparator) => { - const match = RANGE_COMPARATOR.exec(comparator); - if (!match) throw new Error(`range "${text}" contains "${comparator}", which this gate does not read`); - return { operator: match[1] ?? '=', bound: parseRelease(match[2]) }; - }); - }); - return alternatives.some((comparators) => - comparators.every(({ operator, bound }) => comparatorHolds(release, operator, bound)), - ); -} - -/* ── the spec's declared dependencies, re-pointed where the store's do not satisfy them ── */ - -function installedPackageAt(dir) { - if (!fs.existsSync(path.join(dir, 'package.json'))) return null; - const realpath = fs.realpathSync(dir); - const { name, version } = JSON.parse(fs.readFileSync(path.join(realpath, 'package.json'), 'utf8')); - return { name, version, realpath }; -} - -function storeSiblingPath(target, name) { - const entryModules = path.resolve(target, ...SPEC_PACKAGE_NAME.split('/').map(() => '..')); - return path.join(entryModules, ...name.split('/')); -} - -function storeEntryName(target) { - return path.basename(path.resolve(target, ...SPEC_PACKAGE_NAME.split('/').map(() => '..'), '..')); -} - -function judged(name, range, version, whose) { - try { - return satisfiesRange(version, range); - } catch (error) { - return fail( - `cannot judge ${whose} \`${name}@${version}\` against the injected ${SPEC_PACKAGE_NAME}'s declared ` + - `\`${name}: ${range}\`: ${error.message}. Refused rather than guessed -- a range read wrongly here ` + - `decides which dependency the whole compile runs against.`, - ); - } -} - -function planDependencySubstitutions({ dependencies, targets, upstreamCheckout }) { - const plan = []; - for (const [name, range] of Object.entries(dependencies ?? {}).sort(([a], [b]) => a.localeCompare(b))) { - for (const target of targets) { - const sibling = storeSiblingPath(target, name); - const held = installedPackageAt(sibling); - if (held && judged(name, range, held.version, 'the store sibling')) continue; - - const upstreamDir = path.join(upstreamCheckout, 'packages', 'spec', 'node_modules', ...name.split('/')); - const upstream = installedPackageAt(upstreamDir); - const heldText = held ? `\`${name}@${held.version}\`` : 'no copy at all'; - if (!upstream || upstream.name !== name || !judged(name, range, upstream.version, 'the objectstack checkout')) { - fail( - `the injected ${SPEC_PACKAGE_NAME} declares \`${name}: ${range}\`. The store entry ${storeEntryName(target)} ` + - `holds ${heldText}, which does not satisfy it, and the objectstack checkout has ` + - (upstream ? `\`${upstream.name}@${upstream.version}\`` : 'no copy') + - ` at ${upstreamDir}. No copy satisfying the range is available, so nothing was injected: a compile now ` + - `would read the spec's declarations against a dependency they were not built against, and report this ` + - `gate's own injection as a shape break.`, - ); - } - let linkedFrom = null; - try { - linkedFrom = fs.readlinkSync(sibling); - } catch { - // Absent, or a directory rather than a link: nothing to print as its target. - } - plan.push({ name, range, target, sibling, held, linkedFrom, upstream }); - } - } - return plan; -} - -/** REMOVE, then link. ⛔ Never write into what is there: the files are hardlinks into the global store. */ -function relinkSibling({ sibling, upstream }) { - const stat = fs.lstatSync(sibling, { throwIfNoEntry: false }); - if (stat?.isSymbolicLink()) fs.unlinkSync(sibling); - else if (stat) fs.rmSync(sibling, { recursive: true, force: true }); - fs.mkdirSync(path.dirname(sibling), { recursive: true }); - fs.symlinkSync(upstream.realpath, sibling, 'dir'); -} - -function describeSubstitution({ name, range, target, held, linkedFrom, upstream }) { - const entry = storeEntryName(target); - const was = held ? `${name}@${held.version} (-> ${linkedFrom ?? held.realpath})` : 'absent'; - return ( - `substituted ${name} for ${SPEC_PACKAGE_NAME} in ${entry}: declared ${range}, the store held ${was}, which does ` + - `not satisfy it; now -> ${upstream.realpath} (${name}@${upstream.version}, the copy the objectstack checkout's ` + - `spec build resolved)` - ); -} - -const IMPORTER_DEPENDENCY_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']; - -function importerAdmits(name, range, version, importer) { - try { - return satisfiesRange(version, range); - } catch (error) { - return fail( - `cannot judge whether downstream importer ${importer}'s declared \`${name}: ${range}\` admits ${name}@${version}, ` + - `the copy the injected ${SPEC_PACKAGE_NAME} was re-pointed to: ${error.message}. Refused rather than ` + - `guessed -- the answer decides whether this compile reads one copy or two.`, - ); - } -} - -function planImporterRepoints({ repoRoot, plan }) { - const repointed = new Map(); - for (const step of plan) repointed.set(step.name, step.upstream); - if (repointed.size === 0) return { relink: [], twoCopies: [], importers: new Map() }; - - const relink = []; - const twoCopies = []; - const importers = new Map(); - const dirs = importerDirsOrFail(repoRoot); - for (const [name, upstream] of [...repointed].sort(([a], [b]) => a.localeCompare(b))) { - const declaring = []; - for (const dir of dirs) { - const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, dir, 'package.json'), 'utf8')); - const ranges = [ - ...new Set( - IMPORTER_DEPENDENCY_FIELDS.map((field) => manifest[field]?.[name]).filter((range) => typeof range === 'string'), - ), - ]; - if (ranges.length === 0) continue; - const refusing = ranges.filter((range) => !importerAdmits(name, range, upstream.version, dir)); - const link = path.join(repoRoot, dir, 'node_modules', ...name.split('/')); - let linkedFrom = null; - try { - linkedFrom = fs.readlinkSync(link); - } catch { - // Absent, or a directory rather than a link: nothing to print as its target. - } - const entry = { name, importer: dir, ranges, refusing, link, linkedFrom, held: installedPackageAt(link), upstream }; - declaring.push(entry); - (refusing.length === 0 ? relink : twoCopies).push(entry); - } - importers.set(name, declaring); - } - return { relink, twoCopies, importers }; -} - -function describeImporterRepoint({ name, importer, ranges, held, linkedFrom, upstream }) { - const was = held ? `${name}@${held.version} (-> ${linkedFrom ?? held.realpath})` : 'absent'; - return ( - `re-pointed ${name} for downstream importer ${importer}: declares ${ranges.join(' and ')}, which admits ` + - `${upstream.version}; was ${was}, now -> ${upstream.realpath} (${name}@${upstream.version}, the copy the spec ` + - `substitution linked)` - ); -} - -function describeTwoCopies({ name, importer, refusing, upstream }) { - return `two copies of ${name} stay: ${importer} declares ${refusing.join(' and ')}, which does not admit ${upstream.version}`; -} - -function resolvedDependency(fromDir, name) { - const lookup = createRequire(path.join(fromDir, 'package.json')).resolve.paths(name) ?? []; - for (const modulesDir of lookup) { - const found = installedPackageAt(path.join(modulesDir, ...name.split('/'))); - if (found) return found; - } - return null; -} - -/* ── inject ──────────────────────────────────────────────────────────────── */ - -/** - * Replace every installed copy of the spec in the DOWNSTREAM's virtual store - * with the staged one, re-point its store siblings at the dependencies it was - * built against where the installed ones do not satisfy its manifest, and PROVE - * both took by asking the consumers. `upstreamCheckout` is the objectstack - * checkout the tarball was built and packed in: the spec build's own dependency - * resolution lives under its `packages/spec/node_modules`. - */ -export function inject({ tarball, sha, upstreamCheckout, downstream, log = () => {} }) { - const repoRoot = downstream; - if (!repoRoot || !fs.existsSync(path.join(repoRoot, 'pnpm-workspace.yaml'))) { - fail( - `--downstream must be the ${DOWNSTREAM_REPO} checkout to inject into, and ${repoRoot ?? '(none given)'} ` + - `holds no pnpm-workspace.yaml. Without it there is no install to replace and no consumer to verify.`, - ); - } - const upstreamSpecManifest = upstreamCheckout ? path.join(upstreamCheckout, 'packages', 'spec', 'package.json') : null; - let upstreamSpecName = null; - try { - upstreamSpecName = JSON.parse(fs.readFileSync(upstreamSpecManifest, 'utf8')).name; - } catch { - // Reported below with the path that was tried. - } - if (upstreamSpecName !== SPEC_PACKAGE_NAME) { - fail( - `--upstream-checkout must be the objectstack checkout the spec was built in, and ` + - `${upstreamSpecManifest ?? '(none given)'} is not \`${SPEC_PACKAGE_NAME}\`'s manifest. Without it there is ` + - `no source for the dependencies the spec's declarations were emitted against.`, - ); - } - - const targets = findInstalledSpecDirs(repoRoot); - if (targets.length === 0) { - fail( - `no installed \`${SPEC_PACKAGE_NAME}\` found under ${path.join(repoRoot, 'node_modules/.pnpm')}. Nothing was ` + - `injected, so a typecheck run after this point would compile against whatever is installed and report a ` + - `verdict about the PUBLISHED spec under this gate's name. Run \`pnpm install --frozen-lockfile\` in the ` + - `downstream first, or fix the store layout this reader assumes.`, - ); - } - - const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'spec-shape-gate-')); - const staged = extractTarball(tarball, staging); - - const manifest = JSON.parse(fs.readFileSync(path.join(staged, 'package.json'), 'utf8')); - if (manifest.name !== SPEC_PACKAGE_NAME) { - fail( - `the staged package is \`${manifest.name}\`, not \`${SPEC_PACKAGE_NAME}\`. Refusing to write it over the ` + - `installed spec -- a mis-aimed tarball would produce a typecheck whose subject is not the one this gate reports on.`, - ); - } - - const missingTypes = declaredTypesTargets(manifest.exports).filter((target) => !fs.existsSync(path.join(staged, target))); - if (missingTypes.length > 0) { - fail( - `the staged \`${SPEC_PACKAGE_NAME}\` declares ${missingTypes.length} \`types\` target(s) its tarball does not contain:\n` + - missingTypes.map((t) => ` - ${t}`).join('\n') + - `\n\nThis is what a spec built with its declaration pass skipped looks like. Injecting it would fail every ` + - `consumer with TS2307 and this gate would report that as a shape break at ${sha}. Build the spec with ` + - `declarations and pack it again.`, - ); - } - - const plan = planDependencySubstitutions({ dependencies: manifest.dependencies, targets, upstreamCheckout }); - const importerPlan = planImporterRepoints({ repoRoot, plan }); - - for (const target of targets) { - // REMOVE, then copy. ⛔ Never copy over: the files below are hardlinks into - // pnpm's global store, and writing through one corrupts the store for every - // other checkout on this machine. - fs.rmSync(target, { recursive: true, force: true }); - fs.mkdirSync(target, { recursive: true }); - fs.cpSync(staged, target, { recursive: true, dereference: true }); - fs.writeFileSync( - path.join(target, MARKER_FILE), - `${JSON.stringify({ upstream: UPSTREAM_REPO, sha, version: manifest.version, injectedAt: new Date().toISOString() }, null, 2)}\n`, - ); - } - fs.rmSync(staging, { recursive: true, force: true }); - - const substitutions = plan.map((step) => { - relinkSibling(step); - return describeSubstitution(step); - }); - for (const entry of importerPlan.relink) { - relinkSibling({ sibling: entry.link, upstream: entry.upstream }); - substitutions.push(describeImporterRepoint(entry)); - } - for (const entry of importerPlan.twoCopies) substitutions.push(describeTwoCopies(entry)); - - const consumers = findSpecConsumers(repoRoot); - if (consumers.length === 0) { - fail( - `the injection wrote ${targets.length} director(ies), and then no workspace package was found linking to ` + - `\`${SPEC_PACKAGE_NAME}\`. A reading of zero consumers is a broken reader or an install that never happened ` + - `-- not evidence that the injection reached everyone.`, - ); - } - const stranded = consumers.filter((consumer) => !fs.existsSync(path.join(consumer.resolved, MARKER_FILE))); - if (stranded.length > 0) { - fail( - `these ${stranded.length} consumer(s) still resolve \`${SPEC_PACKAGE_NAME}\` to a copy this run did not inject:\n` + - stranded.map((c) => ` - ${c.workspace} -> ${c.resolved}`).join('\n') + - `\n\nA typecheck now would mix the built-from-source spec with the published one, and the green half would be ` + - `indistinguishable from a real pass.`, - ); - } - - const resolutions = []; - for (const consumer of consumers) { - for (const [name, range] of Object.entries(manifest.dependencies ?? {})) { - const found = resolvedDependency(consumer.resolved, name); - resolutions.push({ - workspace: consumer.workspace, - name, - range, - version: found?.version ?? null, - resolved: found?.realpath ?? null, - satisfied: found !== null && judged(name, range, found.version, 'the resolved'), - }); - } - } - const unsatisfied = resolutions.filter((resolution) => !resolution.satisfied); - if (unsatisfied.length > 0) { - fail( - `the injected \`${SPEC_PACKAGE_NAME}\` resolves ${unsatisfied.length} declared dependency reading(s) outside the ` + - `range its manifest declares:\n` + - unsatisfied - .map((r) => ` - ${r.workspace}: ${r.name} ${r.range} -> ` + (r.version ? `${r.name}@${r.version} (${r.resolved})` : 'nothing')) - .join('\n') + - `\n\nA compile now would read the spec's declarations against a dependency they were not built against, and ` + - `report this gate's own injection as a shape break.`, - ); - } - - const oneCopy = []; - for (const [name, declaring] of importerPlan.importers) { - const expected = plan.find((step) => step.name === name).upstream.realpath; - const readers = [ - ...resolutions.filter((r) => r.name === name).map((r) => ({ who: `spec consumer ${r.workspace}`, resolved: r.resolved, version: r.version })), - ...declaring - .filter((entry) => entry.refusing.length === 0) - .map((entry) => { - const found = resolvedDependency(path.join(repoRoot, entry.importer), name); - return { who: `downstream importer ${entry.importer}`, resolved: found?.realpath ?? null, version: found?.version ?? null }; - }), - ]; - const elsewhere = readers.filter((reader) => reader.resolved !== expected); - if (elsewhere.length > 0) { - fail( - `${elsewhere.length} reader(s) of the re-pointed \`${name}\` do not resolve the one copy at ${expected}:\n` + - elsewhere.map((r) => ` - ${r.who} -> ` + (r.version ? `${name}@${r.version} (${r.resolved})` : 'nothing')).join('\n') + - `\n\nA compile now would read two copies of \`${name}\` in one program, and report the artifacts of that as ` + - `shape breaks.`, - ); - } - const specReaders = readers.filter((r) => r.who.startsWith('spec consumer')).length; - oneCopy.push({ - name, - version: readers[0]?.version ?? null, - resolved: expected, - consumers: specReaders, - importers: readers.length - specReaders, - twoCopies: declaring.filter((entry) => entry.refusing.length > 0).map((entry) => entry.importer), - }); - } - - log( - `spec-shape-gate: injected ${SPEC_PACKAGE_NAME}@${manifest.version} built from ${UPSTREAM_REPO}@${sha} into ` + - `${targets.length} store copy/copies of the ${DOWNSTREAM_REPO} checkout; ${consumers.length} workspace consumer(s) verified on it.`, - ); - for (const target of targets) log(` store ${path.relative(repoRoot, target)}`); - for (const consumer of consumers) log(` consumer ${consumer.workspace}`); - for (const line of substitutions) log(`spec-shape-gate: ${line}`); - if (plan.length === 0) { - log(`spec-shape-gate: no dependency substitution -- every declared dependency's store sibling satisfies its range.`); - } - const distinct = new Map(); - for (const r of resolutions) { - const key = JSON.stringify([r.name, r.resolved]); - distinct.set(key, { ...r, consumers: (distinct.get(key)?.consumers ?? 0) + 1 }); - } - for (const r of distinct.values()) { - log(` resolves ${r.name}@${r.version} (declared ${r.range}) for ${r.consumers} consumer(s) -> ${r.resolved}`); - } - for (const c of oneCopy) { - log( - ` one copy of ${c.name}@${c.version} for ${c.consumers} spec consumer(s) and ${c.importers} downstream importer(s) -> ${c.resolved}` + - (c.twoCopies.length > 0 ? `; a second copy stays for ${c.twoCopies.join(', ')}` : ''), - ); - } - return { targets, consumers, version: manifest.version, sha, substitutions, resolutions, oneCopy }; -} - -/* ── the typecheck log, parsed into attributed diagnostics ───────────────── */ - -// turbo prefixes every line of a task's output with `:: ` when -// it STREAMS (locally), and on a GitHub Actions runner it GROUPS instead: a -// `##[group]:` header per task, output emitted BARE under it, -// the failing task announced by a colourised header with no group at all. So -// the workspace is tracked from BOTH carriers: the prefix when it is there, and -// otherwise the header of the task whose output is currently streaming. -const TURBO_PREFIX = /^(?:\x1b\[[0-9;]*m)*([^\s:]+):([^\s:]+(?::[^\s:]+)*):\s?/; -const TURBO_GROUP_HEADER = /^(?:##\[group\])?([^\s:]+):([^\s:]+(?::[^\s:]+)*)$/; -const GROUP_END = /^##\[endgroup\]\s*$/; -const DIAGNOSTIC = /^(.+?)\((\d+),(\d+)\):\s+(error|warning)\s+(TS\d+):\s+(.*)$/; -const ANSI = /\x1b\[[0-9;]*m/g; - -/** - * Parse a captured `pnpm type-check` log into attributed diagnostics. Every row - * carries the downstream file as a REPO-RELATIVE path, because that is what a - * reader can open and what the registry's `file` patterns are written against. - */ -export function parseDiagnostics(logText, repoRoot) { - const dirs = workspaceDirsByName(repoRoot); - const rows = []; - const unmappedPackages = new Set(); - let streaming = null; - - for (const rawLine of logText.split('\n')) { - const line = rawLine.replace(ANSI, ''); - if (GROUP_END.test(line)) { - streaming = null; - continue; - } - const header = line.match(TURBO_GROUP_HEADER); - if (header) { - // Only a name this workspace actually has may become the attribution - // target; anything else clears it. - streaming = dirs.has(header[1]) ? header[1] : null; - continue; - } - const prefixed = line.match(TURBO_PREFIX); - const workspaceName = prefixed ? prefixed[1] : streaming; - const body = prefixed ? line.slice(prefixed[0].length) : line; - const diagnostic = body.match(DIAGNOSTIC); - if (!diagnostic) continue; - - const [, rawFile, lineNo, column, severity, code, message] = diagnostic; - let file = rawFile.trim(); - if (path.isAbsolute(file)) { - file = path.relative(repoRoot, file); - } else if (workspaceName) { - const dir = dirs.get(workspaceName); - if (dir) file = dir === '.' ? file : path.posix.join(dir, file); - else unmappedPackages.add(workspaceName); - } - rows.push({ - workspace: workspaceName ?? '(unprefixed)', - file: file.split(path.sep).join('/'), - line: Number(lineNo), - column: Number(column), - severity, - code, - message, - }); - } - return { rows, unmappedPackages: [...unmappedPackages].sort() }; -} - -/** One row per distinct (file, line, column, code, message), with how often the log repeated it. */ -export function distinctRows(rows) { - const seen = new Map(); - for (const row of rows) { - const key = JSON.stringify([row.file, row.line, row.column, row.code, row.message]); - const hit = seen.get(key); - if (hit) hit.occurrences += 1; - else seen.set(key, { ...row, occurrences: 1 }); - } - return [...seen.values()]; -} - -/* ── THE REGISTRY ────────────────────────────────────────────────────────── */ - -const ENTRY_KEYS = Object.freeze(['retired', 'objectstack_pr', 'objectstack_commit', 'downstream_card', 'registered_at', 'matches']); -const PATTERN_KEYS = Object.freeze(['file', 'code', 'message']); -const CARD_REF = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#[1-9]\d*$/; -const REPO_RELATIVE_FILE = /^(?!\/)(?!\.\.?\/)(?:[^\0/]+\/)*[^\0/]+$/; - -/** A `file` pattern as a RegExp: `**` spans segments, `*` stays inside one, everything else is literal. */ -export function fileGlobToRegExp(glob) { - let source = ''; - for (let i = 0; i < glob.length; i += 1) { - const ch = glob[i]; - if (ch === '*') { - if (glob[i + 1] === '*') { - source += '.*'; - i += 1; - if (glob[i + 1] === '/') i += 1; // `**/` also matches zero directories - } else { - source += '[^/]*'; - } - } else { - source += ch.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); - } - } - return new RegExp(`^${source}$`); -} - -/** - * Parse the retirement registry. Returns `{ retirements }` or THROWS one error - * naming every problem found -- the caller turns that into exit 2, because a - * registry this reader cannot read is not an empty one. - */ -export function parseRegistry(text, file = REGISTRY_FILE) { - const problems = []; - let doc; - try { - doc = JSON.parse(text); - } catch (error) { - throw new Error(`${file} is not JSON: ${error.message}`); - } - if (!doc || typeof doc !== 'object' || Array.isArray(doc) || !Array.isArray(doc.retirements)) { - throw new Error(`${file} must be an object with a \`retirements\` array`); - } - for (const key of Object.keys(doc)) { - if (key !== 'retirements' && key !== '$comment') problems.push(`top-level key "${key}" is not \`retirements\` or \`$comment\``); - } - const retirements = []; - doc.retirements.forEach((entry, index) => { - const where = `retirements[${index}]`; - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { - problems.push(`${where} is not an object`); - return; - } - for (const key of Object.keys(entry)) { - if (!ENTRY_KEYS.includes(key)) problems.push(`${where} carries an unknown key "${key}" (the keys are ${ENTRY_KEYS.join(', ')})`); - } - for (const key of ENTRY_KEYS) { - if (!(key in entry)) problems.push(`${where} lacks "${key}"`); - } - if (typeof entry.retired !== 'string' || entry.retired.trim() === '') problems.push(`${where}.retired must say what was retired`); - if (!Number.isInteger(entry.objectstack_pr) || entry.objectstack_pr <= 0) problems.push(`${where}.objectstack_pr must be a positive integer`); - if (typeof entry.objectstack_commit !== 'string' || !/^[0-9a-f]{40}$/.test(entry.objectstack_commit)) { - problems.push(`${where}.objectstack_commit must be the 40-hex merge commit of that pull request`); - } - if (typeof entry.downstream_card !== 'string' || !CARD_REF.test(entry.downstream_card)) { - problems.push(`${where}.downstream_card must read owner/repo#N (the consumer card that lands the catch-up)`); - } - if (typeof entry.registered_at !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(entry.registered_at)) { - problems.push(`${where}.registered_at must be a YYYY-MM-DD date`); - } - if (!Array.isArray(entry.matches) || entry.matches.length === 0) { - problems.push(`${where}.matches must be a non-empty array -- a registration that matches nothing registers nothing`); - } else { - entry.matches.forEach((pattern, p) => { - const at = `${where}.matches[${p}]`; - if (!pattern || typeof pattern !== 'object' || Array.isArray(pattern)) { - problems.push(`${at} is not an object`); - return; - } - for (const key of Object.keys(pattern)) { - if (!PATTERN_KEYS.includes(key)) problems.push(`${at} carries an unknown key "${key}" (the keys are ${PATTERN_KEYS.join(', ')})`); - } - if (typeof pattern.file !== 'string' || !REPO_RELATIVE_FILE.test(pattern.file)) { - problems.push(`${at}.file must be a repo-relative downstream path or glob (no leading slash or dot segment)`); - } - const hasCode = 'code' in pattern; - const hasMessage = 'message' in pattern; - if (hasCode && (typeof pattern.code !== 'string' || !/^TS\d+$/.test(pattern.code))) problems.push(`${at}.code must read TS`); - if (hasMessage && (typeof pattern.message !== 'string' || pattern.message.trim() === '')) problems.push(`${at}.message must be a non-empty substring of the diagnostic`); - if (!hasCode && !hasMessage) { - problems.push(`${at} names a file and nothing else -- a file alone registers "anything in this file", which is the ignore-all switch this registry must never become; add \`code\` or \`message\``); - } - }); - } - retirements.push(entry); - }); - if (problems.length > 0) { - throw new Error(`${file} has ${problems.length} problem(s):\n` + problems.map((p) => ` - ${p}`).join('\n')); - } - return { retirements }; -} - -/** Does one registry pattern cover one diagnostic row? Every declared field must hold. */ -export function patternCovers(pattern, row) { - if (!fileGlobToRegExp(pattern.file).test(row.file)) return false; - if ('code' in pattern && pattern.code !== row.code) return false; - if ('message' in pattern && !String(row.message).includes(pattern.message)) return false; - return true; -} - -/** - * Split the rows into `known` (a registered retirement covers the row, and - * which entry) and `unknown` (a new break), and name the entries that covered - * nothing this run. - */ -export function classifyRows(rows, registry) { - const known = []; - const unknown = []; - const matchedEntries = new Set(); - for (const row of rows) { - const entry = (registry?.retirements ?? []).find((candidate) => candidate.matches.some((pattern) => patternCovers(pattern, row))); - if (entry) { - known.push({ row, entry }); - matchedEntries.add(entry); - } else { - unknown.push(row); - } - } - const unmatchedEntries = (registry?.retirements ?? []).filter((entry) => !matchedEntries.has(entry)); - return { known, unknown, unmatchedEntries }; -} - -/** The exit code `report` ends with. See the header's exit table. */ -export function verdict({ status, rows, unknown }) { - if (status === 0 && rows.length === 0) return 0; - if (rows.length === 0) return 2; - if (unknown.length > 0) return 1; - return 0; -} - -/* ── THE ATTRIBUTION ─────────────────────────────────────────────────────── */ - -/** The pull request a squash-merged commit title names: the trailing `(#N)`. */ -export function pullNumberFromTitle(title) { - const match = /\(#([1-9]\d*)\)\s*$/.exec(String(title ?? '').split('\n')[0].trim()); - return match ? Number(match[1]) : null; -} - -/** The cards a pull request body closes, by GitHub's own closing keywords -- `Fixes #N`, `closes: #N`, and the rest. */ -export function closingTargets(body) { - const out = []; - const re = /\b(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\b\s*:?\s+#([1-9]\d*)/gi; - for (const match of String(body ?? '').matchAll(re)) { - const n = Number(match[1]); - if (!out.includes(n)) out.push(n); - } - return out; -} - -/** The `domain:*` labels among a label list (names or `{ name }` objects). */ -export function laneLabels(labels) { - return [...new Set((labels ?? []).map((l) => (typeof l === 'string' ? l : l?.name)).filter((n) => typeof n === 'string' && n.startsWith('domain:')))].sort(); -} - -/** - * The newest completed `success` run of this workflow on `main` that is not - * this run and whose head is not this head -- the last green, whose head opens - * the commit range. A `known`-only run concluded `success`, so it counts, which - * is what resets the range after a registration. - */ -export function selectPreviousGreen(runs, { currentSha, currentRunId }) { - const candidates = (runs ?? []).filter( - (run) => - run?.status === 'completed' && - run?.conclusion === 'success' && - run?.head_branch === 'main' && - ATTRIBUTED_EVENTS.includes(run?.event) && - String(run?.id) !== String(currentRunId) && - run?.head_sha !== currentSha, - ); - candidates.sort((a, b) => String(b.run_started_at ?? b.created_at ?? '').localeCompare(String(a.run_started_at ?? a.created_at ?? '')) || Number(b.id) - Number(a.id)); - return candidates[0] ?? null; -} - -async function githubGet({ fetchJson, repo, token, route }) { - const url = `https://api.github.com/repos/${repo}${route}`; - return fetchJson(url, { - headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }, - }); -} - -/** - * The commit range since the last green, each commit's pull request and lane. - * `fetchJson(url, init)` returns `{ ok, status, json }`; the self-test hands in - * a fake, the live caller a thin wrapper over `fetch`. Nothing here throws into - * the verdict: every failure becomes a `note` or an `unattributed` reason. - */ -export async function attributeRange({ repo, token, sha, runId, fetchJson, maxCommits = ATTRIBUTION_MAX_COMMITS }) { - const result = { previousGreen: null, range: null, commits: [], truncated: false, capped: false, lanes: new Map(), notes: [] }; - const workflowFile = path.basename(WORKFLOW_FILE); - const runsRes = await githubGet({ fetchJson, repo, token, route: `/actions/workflows/${workflowFile}/runs?branch=main&status=completed&per_page=100` }); - if (!runsRes.ok || !Array.isArray(runsRes.json?.workflow_runs)) { - result.notes.push(`could not list this workflow's completed runs on main (HTTP ${runsRes.status}); no range can be named`); - return result; - } - const previousGreen = selectPreviousGreen(runsRes.json.workflow_runs, { currentSha: sha, currentRunId: runId }); - if (!previousGreen) { - result.notes.push( - `no earlier green run of this workflow on main among the ${runsRes.json.workflow_runs.length} newest completed run(s) -- the first runs, or a red standing since the first run; no range can be named`, - ); - return result; - } - result.previousGreen = { id: previousGreen.id, head_sha: previousGreen.head_sha, run_started_at: previousGreen.run_started_at, html_url: previousGreen.html_url }; - - const compareRes = await githubGet({ fetchJson, repo, token, route: `/compare/${previousGreen.head_sha}...${sha}?per_page=250` }); - if (!compareRes.ok || !Array.isArray(compareRes.json?.commits)) { - result.notes.push(`the compare ${String(previousGreen.head_sha).slice(0, 10)}...${String(sha).slice(0, 10)} could not be read (HTTP ${compareRes.status})`); - return result; - } - result.range = { base: previousGreen.head_sha, head: sha, status: compareRes.json.status, total: compareRes.json.total_commits, html_url: compareRes.json.html_url }; - result.truncated = Number(compareRes.json.total_commits) > compareRes.json.commits.length; - - const listed = compareRes.json.commits.slice().reverse(); // newest first - const toRead = listed.slice(0, maxCommits); - result.capped = listed.length > toRead.length; - for (const c of toRead) { - const title = String(c?.commit?.message ?? '').split('\n')[0]; - const pull = pullNumberFromTitle(title); - const commit = { sha: c.sha, title, pull, lanes: [], cards: [], reason: null }; - if (pull === null) { - commit.reason = 'the title names no pull request'; - } else { - const pullRes = await githubGet({ fetchJson, repo, token, route: `/pulls/${pull}` }); - if (!pullRes.ok) { - commit.reason = `pull ${pull} could not be read (HTTP ${pullRes.status})`; - } else { - const lanes = new Set(laneLabels(pullRes.json?.labels)); - for (const card of closingTargets(pullRes.json?.body)) { - const cardRes = await githubGet({ fetchJson, repo, token, route: `/issues/${card}` }); - if (!cardRes.ok) { - commit.cards.push({ number: card, lanes: [], reason: `HTTP ${cardRes.status}` }); - continue; - } - const cardLanes = laneLabels(cardRes.json?.labels); - commit.cards.push({ number: card, lanes: cardLanes, reason: null }); - for (const lane of cardLanes) lanes.add(lane); - } - commit.lanes = [...lanes].sort(); - if (commit.lanes.length === 0) commit.reason = commit.cards.length === 0 ? `pull ${pull} carries no domain label and closes no card` : `neither pull ${pull} nor its card(s) carry a domain label`; - } - } - for (const lane of commit.lanes.length > 0 ? commit.lanes : ['unattributed']) { - result.lanes.set(lane, (result.lanes.get(lane) ?? 0) + 1); - } - result.commits.push(commit); - } - return result; -} - -/** The markdown block the attribution renders as, in the run summary. */ -export function renderAttribution(attribution) { - const out = ['### Attribution -- the objectstack commits since the last green run', '']; - if (!attribution) return out.concat(['_Not attributed: this event has no range to name._']).join('\n'); - const short = (s) => String(s ?? '').slice(0, 10); - if (!attribution.range) { - out.push('⚠️ No commit range could be named for this red run.'); - for (const note of attribution.notes) out.push(`- ${note}`); - out.push('', 'Read the run log directly. ⛔ The absence of a range is the absence of a READING, not evidence that nothing changed.'); - return out.join('\n'); - } - const { range, previousGreen, commits } = attribution; - out.push( - `Range: \`${short(range.base)}\` (run ${previousGreen.id}, the last green run of this workflow on main) .. \`${short(range.head)}\`` + - (range.html_url ? ` -- [compare](${range.html_url})` : '') + - ` -- ${range.total} commit(s)` + - (attribution.truncated ? ' (the compare API truncated the list; open the compare link for the rest)' : '') + - (attribution.capped ? ` (pull requests read for the newest ${commits.length} only)` : ''), - '', - ); - if (commits.length === 0) { - out.push('⚠️ Nothing landed in that range: the same tree was green last time and is red now, so this is a flake, an infrastructure fault, or the DOWNSTREAM moved (objectui `main` is cloned fresh each run). ⛔ Do not go looking for the objectstack commit that broke it.'); - } else { - const lanes = [...attribution.lanes.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); - out.push(`**Lanes implicated:** ${lanes.map(([lane, n]) => `\`${lane}\` (${n})`).join(', ')}`, ''); - out.push('| commit | pull | card(s) | lane(s) |', '| --- | --- | --- | --- |'); - for (const c of commits) { - const cards = c.cards.length > 0 ? c.cards.map((k) => `#${k.number}${k.reason ? ` (${k.reason})` : ''}`).join(', ') : '--'; - const lane = c.lanes.length > 0 ? c.lanes.map((l) => `\`${l}\``).join(', ') : `unattributed${c.reason ? ` -- ${c.reason}` : ''}`; - out.push(`| \`${short(c.sha)}\` ${c.title.replace(/\|/g, '\\|')} | ${c.pull ? `#${c.pull}` : '--'} | ${cards} | ${lane} |`); - } - } - for (const note of attribution.notes) out.push('', `⚠️ ${note}`); - out.push('', 'Under the two-speed rule 3 of #22998 a new break is the implicated lane\'s FIRST work: its next pull request carries the fix, or -- for an intentional retirement -- the accepting seat files the downstream card and registers the retirement in `' + REGISTRY_FILE + '` in the same act, and the next hourly run reads it as `known`. A lane written `unattributed` is a reading this gate could not take, never a lane with no owner.'); - return out.join('\n'); -} - -/* ── the summary and the annotations ─────────────────────────────────────── */ - -function diagnosticsTable(rows, extra = () => '') { - const out = ['| objectui file | line | code | message | |', '| --- | --- | --- | --- | --- |']; - for (const row of rows.slice(0, 50)) { - out.push(`| \`${row.file}\` | ${row.line} | ${row.code} | ${String(row.message).replace(/\|/g, '\\|')}${row.occurrences > 1 ? ` (x${row.occurrences})` : ''} | ${extra(row)} |`); - } - if (rows.length > 50) out.push(`| ... | | | ${rows.length - 50} more, in the job log | |`); - return out; -} - -/** The markdown this gate puts in front of a human. */ -export function renderSummary({ sha, downstreamSha = null, commit = null, event = null, status, rows, known, unknown, unmatchedEntries, unmappedPackages, attribution = null }) { - const short = String(sha).slice(0, 12); - const link = `https://github.com/${UPSTREAM_REPO}/commit/${sha}`; - const out = []; - out.push(`### Spec Shape Gate -- \`${UPSTREAM_REPO}@${short}\` against \`${DOWNSTREAM_REPO}@${String(downstreamSha ?? 'main').slice(0, 12)}\``, ''); - out.push(`Compiled ${DOWNSTREAM_REPO} at \`main\`${downstreamSha ? ` (\`${String(downstreamSha).slice(0, 12)}\`)` : ''} against \`${SPEC_PACKAGE_NAME}\` and every other \`@objectstack/*\` package it installs, built and packed from [${short}](${link})${event ? ` (\`${event}\` run)` : ''}.`); - if (commit) out.push('', `> ${commit}`); - out.push(''); - - if (status === 0 && rows.length === 0) { - out.push(`✅ ${DOWNSTREAM_REPO} type-checks against \`@objectstack/*\` at that commit.`); - } else if (rows.length === 0) { - out.push( - `⛔ The typecheck failed (exit ${status}) and **no diagnostic could be attributed to a file**.`, - '', - `That is not a shape break this gate can hand to anybody: the whole point of compiling a consumer is that the ` + - `change in objectstack which moved the shape is identifiable from the objectui file that stopped compiling. Read ` + - `the job log -- the failure is upstream of the compiler (an install, a build, the spec's own build) or the ` + - `compiler's output shape moved. This run is exit 2: not a pass, not a shape break.`, - ); - } else { - const unknownRows = distinctRows(unknown); - const knownRows = distinctRows(known.map((k) => k.row)); - const entryFor = (row) => known.find((k) => k.row.file === row.file && k.row.line === row.line && k.row.code === row.code && k.row.message === row.message)?.entry; - if (unknownRows.length > 0) { - const files = [...new Set(unknownRows.map((row) => row.file))].sort(); - out.push( - `⛔ **${unknownRows.length} NEW diagnostic(s) in ${files.length} objectui file(s)** against \`${UPSTREAM_REPO}@${short}\` that no registered retirement covers.`, - '', - `Each row is an objectui file that compiles against the published spec objectui pins and does not compile against this commit. The objectstack pull request that moved the shape is the one that answers; the attribution below names the candidates.`, - '', - ...diagnosticsTable(unknownRows), - '', - `**Files:** ${files.map((file) => `\`${file}\``).join(', ')}`, - ); - } else { - out.push(`✅ No new shape break: every diagnostic below is a REGISTERED retirement.`); - } - if (knownRows.length > 0) { - out.push( - '', - `#### known -- ${knownRows.length} diagnostic(s) covered by ${new Set(known.map((k) => k.entry)).size} registered retirement(s)`, - '', - ...diagnosticsTable(knownRows, (row) => { - const entry = entryFor(row); - return entry ? `known (registered: ${entry.downstream_card}, objectstack PR #${entry.objectstack_pr}: ${entry.retired})` : 'known'; - }), - ); - } - out.push('', `**Against:** \`${UPSTREAM_REPO}@${sha}\` -- ${link}`); - } - - if (unmatchedEntries?.length > 0) { - out.push('', `ℹ️ ${unmatchedEntries.length} registered retirement(s) matched nothing in this run -- objectui has caught up (prune the entry when its card closes) or the pattern never matched (fix it on the registration's own pull request):`); - for (const entry of unmatchedEntries) out.push(`- ${entry.downstream_card} (objectstack PR #${entry.objectstack_pr}: ${entry.retired})`); - } - if (unmappedPackages?.length > 0) { - out.push('', `⚠️ ${unmappedPackages.length} turbo package name(s) did not resolve to a directory (${unmappedPackages.join(', ')}), so the paths they contributed are package-relative. That is a reader problem in this gate, not a finding about the spec.`); - } - if (rows.length > 0 && unknown.length > 0) out.push('', renderAttribution(attribution)); - return out.join('\n'); -} - -/** - * GitHub annotations, one per NEW diagnostic up to the platform's per-step cap. - * The objectui path rides in the TITLE and the message, not in `file=`: a - * `file=` naming a path this repository does not hold is re-homed by the - * platform onto the workflow file, which is the one thing the annotation must - * not say. - */ -export function annotationLines(unknownRows, sha) { - const short = String(sha).slice(0, 12); - const rows = distinctRows(unknownRows); - const lines = rows.slice(0, ANNOTATION_CAP).map((row) => { - const title = `spec shape break: objectui ${row.file}:${row.line} (objectstack ${short})`; - const message = `${row.code}: ${row.message} -- ${DOWNSTREAM_REPO} ${row.file}:${row.line}:${row.column}, compiled against ${SPEC_PACKAGE_NAME} built from ${UPSTREAM_REPO}@${short}`; - return `::error title=${title.replace(/[,:]/g, (c) => (c === ',' ? '%2C' : '%3A'))}::${message.replace(/\r?\n/g, ' ')}`; - }); - if (rows.length > ANNOTATION_CAP) { - lines.push(`::error title=spec shape break (objectstack ${short})::${rows.length - ANNOTATION_CAP} more new diagnostic(s) -- the run summary carries every row.`); - } - return lines; -} - -async function liveFetchJson(url, init) { - try { - const res = await fetch(url, init); - let json = null; - try { - json = await res.json(); - } catch { - json = null; - } - return { ok: res.ok, status: res.status, json }; - } catch (error) { - return { ok: false, status: `network: ${error.message}`, json: null }; - } -} - -async function report({ logPath, sha, downstreamSha, status, commit, event, downstream, registryPath }) { - let registry; - try { - registry = parseRegistry(fs.readFileSync(registryPath, 'utf8'), registryPath); - } catch (error) { - fail(`${error.message}\n\nA registry this reader cannot read is not an empty one: nothing was judged, and this run is exit 2.`); - } - const logText = fs.readFileSync(logPath, 'utf8'); - const { rows, unmappedPackages } = parseDiagnostics(logText, downstream); - const { known, unknown, unmatchedEntries } = classifyRows(rows, registry); - - let attribution = null; - if (unknown.length > 0 && ATTRIBUTED_EVENTS.includes(event)) { - const repo = process.env.GITHUB_REPOSITORY ?? UPSTREAM_REPO; - const token = process.env.GITHUB_TOKEN ?? ''; - if (!token) { - attribution = { previousGreen: null, range: null, commits: [], truncated: false, capped: false, lanes: new Map(), notes: ['GITHUB_TOKEN is not set, so neither this workflow\'s runs nor the compare could be read'] }; - } else { - attribution = await attributeRange({ repo, token, sha, runId: process.env.GITHUB_RUN_ID ?? '', fetchJson: liveFetchJson }); - } - } - - const summary = renderSummary({ sha, downstreamSha, commit, event, status, rows, known, unknown, unmatchedEntries, unmappedPackages, attribution }); - if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`); - process.stdout.write(`${summary}\n`); - - const code = verdict({ status, rows, unknown }); - if (code === 1) for (const line of annotationLines(unknown, sha)) process.stdout.write(`${line}\n`); - if (code === 0 && rows.length > 0) { - process.stdout.write(`spec-shape-gate: ${distinctRows(rows).length} diagnostic(s), all registered retirements -- known, not red.\n`); - } - return code; -} - -/* ─────────────────────────── self-test ───────────────────────────────────── - * Offline, no install, no network. It exercises BOTH directions of every - * judgement that decides a verdict: a parser demonstrated only on input it - * parses is not demonstrated, and a registry reader shown only a valid entry - * has not been shown to refuse one. - * - * Floor: what is pinned is the registered battery NAMES and a minimum case - * count per battery, never one total -- a battery falling 40 -> 3 still prints - * a non-zero count. Handshake: the verdict sets `selfTestReachedVerdict` as its - * LAST statement, and the dispatch refuses when it is unset, so a `return` - * above the verdict cannot report as a pass. Copied from - * `scripts/check-agent-model-declared.mjs`, never imported: every self-test - * keeps running standalone. - */ -let selfTestReachedVerdict = false; - -const SELF_TEST_BATTERIES = Object.freeze({ - 'diagnostics: parse and attribute to a downstream file': 9, - 'summary: both directions': 5, - 'inject: staged package shape and store readers': 5, - 'ranges: satisfiesRange and workspace globs': 11, - 'registry: load, refuse, match': 13, - 'attribution: commit range and lanes': 11, - 'verdict: exit code mapping and annotations': 6, -}); -const SELF_TEST_BATTERY_FLOOR = 7; -const UNATTRIBUTED_BATTERY = '(no battery open)'; - -/** A synthetic downstream: the workspace file pnpm reads, four manifests, no install. */ -function syntheticDownstream() { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spec-shape-gate-selftest-')); - fs.writeFileSync(path.join(root, 'pnpm-workspace.yaml'), "packages:\n - 'packages/*'\n - 'apps/*'\n"); - fs.writeFileSync(path.join(root, 'package.json'), '{"name":"object-ui-root"}'); - for (const [dir, name] of [ - ['packages/core', '@object-ui/core'], - ['packages/plugin-grid', '@object-ui/plugin-grid'], - ['packages/react', '@object-ui/react'], - ['apps/console', '@object-ui/console'], - ]) { - fs.mkdirSync(path.join(root, dir), { recursive: true }); - fs.writeFileSync(path.join(root, dir, 'package.json'), JSON.stringify({ name })); - } - return root; -} - -/** The registry entry the positive control is registered with, as the self-test's fixture. */ -const FIXTURE_ENTRY = Object.freeze({ - retired: "view-level data source provider 'api'", - objectstack_pr: 23160, - objectstack_commit: '1912af47ba6e3b250f7c717fea0d56e181029b9c', - downstream_card: 'objectstack-ai/objectui#999999', - registered_at: '2026-10-13', - matches: [ - { file: 'packages/react/src/hooks/useViewData.ts', code: 'TS2367', message: '\'"api"\'' }, - { file: 'packages/core/src/adapters/resolveDataSource.ts', code: 'TS2678', message: '\'"api"\'' }, - { file: 'packages/core/src/adapters/resolveDataSource.ts', code: 'TS2339', message: "on type 'never'" }, - ], -}); - -async function selfTest() { - const ESC = String.fromCharCode(27); - const results = []; - const batterySeen = new Map(); - let openBattery = null; - const battery = (name) => { - openBattery = name; - }; - const check = (name, actual, expected) => { - const b = openBattery ?? UNATTRIBUTED_BATTERY; - batterySeen.set(b, (batterySeen.get(b) ?? 0) + 1); - const ok = JSON.stringify(actual) === JSON.stringify(expected); - results.push({ name, ok, actual, expected }); - }; - const threw = (fn) => { - try { - fn(); - return false; - } catch { - return true; - } - }; - const thrownMessage = (fn) => { - try { - fn(); - return ''; - } catch (error) { - return String(error.message); - } - }; - - const downstream = syntheticDownstream(); - const sha = 'a'.repeat(40); - - battery('diagnostics: parse and attribute to a downstream file'); - const fixture = [ - '@object-ui/core:type-check: > tsc --noEmit', - '@object-ui/core:type-check: src/registry.ts(12,5): error TS2339: Property nope does not exist.', - `${ESC}[32m@object-ui/plugin-grid:type-check:${ESC}[0m src/grid.tsx(4,1): error TS2724: no export.`, - '//:type-check:e2e: e2e/app.spec.ts(9,3): error TS2554: Expected 1 arguments.', - '@object-ui/core:type-check: this line is prose about (1,2): error TS9999 inside a sentence', - ].join('\n'); - const parsed = parseDiagnostics(fixture, downstream); - check('attributes a package-relative path to its workspace directory', parsed.rows[0]?.file, 'packages/core/src/registry.ts'); - check('parses a colourised prefix', parsed.rows[1]?.file, 'packages/plugin-grid/src/grid.tsx'); - check('maps the root task to the repo root', parsed.rows[2]?.file, 'e2e/app.spec.ts'); - check('carries the code through', parsed.rows[1]?.code, 'TS2724'); - check('a green log yields zero rows', parseDiagnostics(['@object-ui/core:type-check: > tsc --noEmit', ' Tasks: 81 successful, 81 total'].join('\n'), downstream).rows.length, 0); - const grouped = [ - '##[group]@object-ui/console:type-check', - '> tsc --noEmit && tsc -p tsconfig.test.json', - '##[endgroup]', - `${ESC}[;31m@object-ui/react:type-check${ESC}[;0m`, - '> tsc --noEmit && tsc -p tsconfig.test.json', - "src/hooks/useNavigationOverlay.ts(76,3): error TS2322: Type 'string' is not assignable.", - ].join('\n'); - check('attributes a BARE diagnostic to the task whose section it is streaming in', parseDiagnostics(grouped, downstream).rows[0]?.file, 'packages/react/src/hooks/useNavigationOverlay.ts'); - check( - 'a diagnostic in no task section is NOT charged to a package', - parseDiagnostics(['##[endgroup]', "src/hooks/useNavigationOverlay.ts(76,3): error TS2322: Type 'string' is not assignable."].join('\n'), downstream).rows[0]?.file, - 'src/hooks/useNavigationOverlay.ts', - ); - check( - 'a header-shaped line naming no workspace clears the attribution', - parseDiagnostics(['##[group]@object-ui/react:type-check', 'totally:unrelated', "src/hooks/useNavigationOverlay.ts(76,3): error TS2322: Type 'string' is not assignable."].join('\n'), downstream).rows[0]?.file, - 'src/hooks/useNavigationOverlay.ts', - ); - check( - 'distinctRows folds the two tsc programs of one task into one row with an occurrence count', - distinctRows(parseDiagnostics([grouped, grouped].join('\n'), downstream).rows).map((r) => r.occurrences), - [2], - ); - - battery('summary: both directions'); - const base = { sha, downstreamSha: 'b'.repeat(40), status: 0, rows: [], known: [], unknown: [], unmatchedEntries: [], unmappedPackages: [] }; - check('a green summary says so', renderSummary(base).includes('type-checks against'), true); - check('an unattributable failure refuses to read as a shape break', renderSummary({ ...base, status: 1 }).includes('no diagnostic could be attributed to a file'), true); - check( - 'a red summary names the file AND the commit', - (() => { - const text = renderSummary({ ...base, status: 1, rows: parsed.rows, unknown: parsed.rows }); - return text.includes('packages/core/src/registry.ts') && text.includes(sha) && text.includes('NEW diagnostic'); - })(), - true, - ); - const knownRow = { workspace: '@object-ui/react', file: 'packages/react/src/hooks/useViewData.ts', line: 124, column: 5, severity: 'error', code: 'TS2367', message: 'This comparison appears to be unintentional because the types \'"object" | "value" | "schema" | undefined\' and \'"api"\' have no overlap.' }; - check( - 'a known-only summary says no new break and names the registration', - (() => { - const text = renderSummary({ ...base, status: 1, rows: [knownRow], known: [{ row: knownRow, entry: FIXTURE_ENTRY }] }); - return text.includes('No new shape break') && text.includes('known (registered: objectstack-ai/objectui#999999') && !text.includes('NEW diagnostic'); - })(), - true, - ); - check('an entry that matched nothing is listed for pruning', renderSummary({ ...base, unmatchedEntries: [FIXTURE_ENTRY] }).includes('matched nothing in this run'), true); - - battery('inject: staged package shape and store readers'); - check( - 'reads every `types` target out of a conditional exports map', - declaredTypesTargets({ '.': { import: { types: './dist/index.d.mts', default: './dist/index.mjs' } }, './ui': { require: { types: './dist/ui/index.d.ts', default: './dist/ui/index.js' } } }), - ['./dist/index.d.mts', './dist/ui/index.d.ts'], - ); - check('an exports map with no types target yields none', declaredTypesTargets({ '.': './x.js' }), []); - const store = path.join(downstream, 'node_modules', '.pnpm'); - const one = path.join(store, '@objectstack+spec@17.4.0_ai@7.0.0_', 'node_modules', '@objectstack', 'spec'); - fs.mkdirSync(one, { recursive: true }); - fs.writeFileSync(path.join(one, 'package.json'), '{"name":"@objectstack/spec"}'); - fs.mkdirSync(path.join(store, '@objectstack+formula@17.4.0', 'node_modules', '@objectstack', 'formula'), { recursive: true }); - check('finds a peer-suffixed store copy', findInstalledSpecDirs(downstream).length, 1); - check('does not claim a sibling @objectstack package', findInstalledSpecDirs(downstream)[0], one); - check('an empty tree finds none', findInstalledSpecDirs(path.join(downstream, 'nowhere')).length, 0); - - battery('ranges: satisfiesRange and workspace globs'); - check('a caret range refuses an older minor', satisfiesRange('4.4.3', '^4.6.1'), false); - check('a caret range admits its floor and a later minor', [satisfiesRange('4.6.1', '^4.6.1'), satisfiesRange('4.7.0', '^4.6.1')], [true, true]); - check('a caret range refuses the next major', satisfiesRange('5.0.0', '^4.6.1'), false); - check('a caret range on 0.x stops at the next minor', [satisfiesRange('0.3.9', '^0.3.1'), satisfiesRange('0.4.0', '^0.3.1')], [true, false]); - check('a tilde range stops at the next minor', [satisfiesRange('2.14.9', '~2.14.0'), satisfiesRange('2.15.0', '~2.14.0')], [true, false]); - check('an exact version admits only itself', [satisfiesRange('2.14.0', '2.14.0'), satisfiesRange('2.14.1', '2.14.0')], [true, false]); - check( - 'a comparator set is an AND, a union an OR', - [satisfiesRange('17.9.0', '>=17.1.0 <18.0.0'), satisfiesRange('18.0.0', '>=17.1.0 <18.0.0'), satisfiesRange('3.0.0', '^1.0.0 || ^3.0.0'), satisfiesRange('2.0.0', '^1.0.0 || ^3.0.0')], - [true, false, true, false], - ); - const refuses = (version, range) => threw(() => satisfiesRange(version, range)); - check( - 'an unreadable spelling throws rather than guessing', - [refuses('4.6.1', 'workspace:*'), refuses('4.6.1', '4.x'), refuses('4.6.1', '^4.6'), refuses('4.7.0-beta.1', '^4.6.1'), refuses('4.6.1', '^4.6.1 || latest')], - [true, true, true, true, true], - ); - check( - 'reads the packages globs of a pnpm-workspace.yaml, and nothing after them', - parseWorkspaceGlobs("# c\npackages:\n - 'packages/*'\n - \"docs\" # tail\n - apps/*\nonlyBuiltDependencies:\n - esbuild\n"), - ['packages/*', 'docs', 'apps/*'], - ); - check( - 'a glob the reader does not know throws rather than dropping importers', - [threw(() => parseWorkspaceGlobs("packages:\n - 'packages/**'\n")), threw(() => parseWorkspaceGlobs("packages:\n - '!packages/x'\n")), threw(() => parseWorkspaceGlobs('overrides: {}\n'))], - [true, true, true], - ); - check('workspaceDirsByName is derived from the workspace file, root included', [...workspaceDirsByName(downstream).entries()].sort(), [['//', '.'], ['@object-ui/console', 'apps/console'], ['@object-ui/core', 'packages/core'], ['@object-ui/plugin-grid', 'packages/plugin-grid'], ['@object-ui/react', 'packages/react']].sort()); - - battery('registry: load, refuse, match'); - const registryText = (entries) => JSON.stringify({ retirements: entries }); - check('a well-formed entry loads', parseRegistry(registryText([FIXTURE_ENTRY])).retirements.length, 1); - check('an empty registry loads as zero retirements', parseRegistry(registryText([])).retirements.length, 0); - check('a registry that is not JSON is refused', thrownMessage(() => parseRegistry('{ nope', 'r.json')).includes('is not JSON'), true); - check('a top-level array is refused', threw(() => parseRegistry('[]')), true); - check('an entry without its downstream card is refused by name', thrownMessage(() => parseRegistry(registryText([{ ...FIXTURE_ENTRY, downstream_card: undefined }]))).includes('lacks "downstream_card"'), true); - check('a file-only pattern is refused as the ignore-all switch', thrownMessage(() => parseRegistry(registryText([{ ...FIXTURE_ENTRY, matches: [{ file: 'packages/react/src/x.ts' }] }]))).includes('ignore-all switch'), true); - check('an empty matches array is refused', thrownMessage(() => parseRegistry(registryText([{ ...FIXTURE_ENTRY, matches: [] }]))).includes('registers nothing'), true); - check('an unknown entry key is refused', thrownMessage(() => parseRegistry(registryText([{ ...FIXTURE_ENTRY, ignore_all: true }]))).includes('unknown key "ignore_all"'), true); - check('a short commit, a bad card ref and a bad date are each named', (() => { - const msg = thrownMessage(() => parseRegistry(registryText([{ ...FIXTURE_ENTRY, objectstack_commit: '1912af47', downstream_card: 'objectui 999999', registered_at: 'yesterday' }]))); - return msg.includes('objectstack_commit') && msg.includes('downstream_card') && msg.includes('registered_at') && msg.includes('3 problem(s)'); - })(), true); - check('a pattern covers a row only when every declared field holds', [ - patternCovers(FIXTURE_ENTRY.matches[0], knownRow), - patternCovers({ ...FIXTURE_ENTRY.matches[0], code: 'TS2322' }, knownRow), - patternCovers({ ...FIXTURE_ENTRY.matches[0], message: 'no overlap at all, really' }, knownRow), - patternCovers({ file: 'packages/other/src/hooks/useViewData.ts', code: 'TS2367' }, knownRow), - ], [true, false, false, false]); - check('a file glob spans segments with ** and stays inside one with *', [ - patternCovers({ file: 'packages/**/useViewData.ts', code: 'TS2367' }, knownRow), - patternCovers({ file: 'packages/*/src/hooks/*.ts', code: 'TS2367' }, knownRow), - patternCovers({ file: 'packages/*/useViewData.ts', code: 'TS2367' }, knownRow), - fileGlobToRegExp('a.b/*.ts').test('aXb/c.ts'), - ], [true, true, false, false]); - const newRow = { ...knownRow, file: 'packages/react/src/hooks/useViewData.ts', line: 200, code: 'TS2322', message: "Type 'string' is not assignable to type 'number'." }; - const classified = classifyRows([knownRow, newRow], parseRegistry(registryText([FIXTURE_ENTRY]))); - check('classifyRows splits a registered row from a new one in the same file', [classified.known.length, classified.unknown.length, classified.known[0]?.entry.downstream_card, classified.unmatchedEntries.length], [1, 1, 'objectstack-ai/objectui#999999', 0]); - check('an entry that covers no row is reported as unmatched, and nothing is known', (() => { - const c = classifyRows([newRow], parseRegistry(registryText([FIXTURE_ENTRY]))); - return [c.known.length, c.unknown.length, c.unmatchedEntries.length]; - })(), [0, 1, 1]); - - battery('attribution: commit range and lanes'); - check('the squash suffix names the pull request', [pullNumberFromTitle('feat(spec)!: retire the view-level api data source (#23160)'), pullNumberFromTitle('fix: no suffix'), pullNumberFromTitle('chore (#12) then more')], [23160, null, null]); - check('closing keywords name cards; a negated spelling does not', closingTargets('Fixes #23082\nClause: no\n\n#23090 is not addressed here; closes: #7, resolved #7 again, and refs #9'), [23082, 7]); - check('laneLabels keeps domain labels only, from names or objects', laneLabels([{ name: 'domain:spec' }, 'size/l', { name: 'domain:ui' }, 'domain:spec']), ['domain:spec', 'domain:ui']); - const run = (id, head_sha, conclusion, extra = {}) => ({ id, head_sha, head_branch: 'main', event: 'schedule', status: 'completed', conclusion, run_started_at: `2026-10-13T${String(id).padStart(2, '0')}:00:00Z`, ...extra }); - const H = ['1'.repeat(40), '2'.repeat(40), '3'.repeat(40)]; - check('selectPreviousGreen takes the newest success that is not this run or this head', selectPreviousGreen([run(1, H[0], 'success'), run(2, H[1], 'success'), run(3, H[2], 'failure'), run(4, H[2], 'success')], { currentSha: H[2], currentRunId: 9 })?.id, 2); - check('selectPreviousGreen ignores a pull_request run, a non-main run and this run id', selectPreviousGreen([run(5, H[1], 'success', { event: 'pull_request' }), run(6, H[1], 'success', { head_branch: 'feature' }), run(7, H[1], 'success')], { currentSha: H[2], currentRunId: 7 }), null); - const board = { - runs: [run(1, H[0], 'success'), run(2, H[1], 'success'), run(3, H[2], 'failure')], - commits: [ - { sha: 'c1'.repeat(20), commit: { message: 'fix(core): first (#101)\n\nbody' } }, - { sha: 'c2'.repeat(20), commit: { message: 'docs: untracked change' } }, - { sha: 'c3'.repeat(20), commit: { message: 'feat(spec)!: retire x (#103)' } }, - ], - pulls: { - 101: { labels: [{ name: 'size/s' }], body: 'Fixes #501\n' }, - 103: { labels: [{ name: 'domain:ui' }], body: 'Part of #503 and closes #504' }, - }, - issues: { 501: { labels: [{ name: 'domain:spec' }] }, 504: { labels: [] } }, - }; - const fakeFetch = (overrides = {}) => async (url) => { - const route = url.replace(/^https:\/\/api\.github\.com\/repos\/[^/]+\/[^/]+/, ''); - if (route.startsWith('/actions/workflows/spec-shape-gate.yml/runs')) return overrides.runs ?? { ok: true, status: 200, json: { workflow_runs: board.runs } }; - if (route.startsWith('/compare/')) return overrides.compare ?? { ok: true, status: 200, json: { status: 'ahead', total_commits: board.commits.length, html_url: 'https://example.invalid/compare', commits: board.commits } }; - const pull = /^\/pulls\/(\d+)$/.exec(route); - if (pull) return board.pulls[pull[1]] ? { ok: true, status: 200, json: board.pulls[pull[1]] } : { ok: false, status: 404, json: null }; - const issue = /^\/issues\/(\d+)$/.exec(route); - if (issue) return board.issues[issue[1]] ? { ok: true, status: 200, json: board.issues[issue[1]] } : { ok: false, status: 404, json: null }; - return { ok: false, status: 500, json: null }; - }; - const attributed = await attributeRange({ repo: 'o/r', token: 't', sha: H[2], runId: 3, fetchJson: fakeFetch() }); - check('the range opens at the last green head and lists the commits newest first', [attributed.range?.base, attributed.commits.map((c) => c.sha.slice(0, 2))], [H[1], ['c3', 'c2', 'c1']]); - check('a lane is read from the card the pull fixes, from the pull\'s own label, or written unattributed with the reason', attributed.commits.map((c) => [c.pull, c.lanes, c.reason]), [ - [103, ['domain:ui'], null], - [null, [], 'the title names no pull request'], - [101, ['domain:spec'], null], - ]); - check('the lane tally counts commits per lane, unattributed included', [...attributed.lanes.entries()].sort(), [['domain:spec', 1], ['domain:ui', 1], ['unattributed', 1]]); - const capped = await attributeRange({ repo: 'o/r', token: 't', sha: H[2], runId: 3, fetchJson: fakeFetch(), maxCommits: 1 }); - check('a cap on the commits read is applied newest first and said', [capped.capped, capped.commits.length, capped.commits[0].pull], [true, 1, 103]); - const noGreen = await attributeRange({ repo: 'o/r', token: 't', sha: H[0], runId: 1, fetchJson: fakeFetch({ runs: { ok: true, status: 200, json: { workflow_runs: [run(1, H[0], 'failure')] } } }) }); - const noCompare = await attributeRange({ repo: 'o/r', token: 't', sha: H[2], runId: 3, fetchJson: fakeFetch({ compare: { ok: false, status: 422, json: null } }) }); - check('no previous green and a failed compare each leave no range and say which', [noGreen.range, noGreen.notes[0]?.includes('no earlier green run'), noCompare.range, noCompare.notes[0]?.includes('HTTP 422')], [null, true, null, true]); - check('renderAttribution: no range warns, an empty range names the flake reading, a table names the lane', [ - renderAttribution(noGreen).includes('No commit range could be named'), - renderAttribution({ ...attributed, commits: [], lanes: new Map() }).includes('flake'), - renderAttribution(attributed).includes('`domain:spec`') && renderAttribution(attributed).includes('unattributed -- the title names no pull request'), - renderAttribution(null).includes('no range to name'), - ], [true, true, true, true]); - - battery('verdict: exit code mapping and annotations'); - check('a green compile with no rows is 0', verdict({ status: 0, rows: [], unknown: [] }), 0); - check('a failed compile with no attributable row is 2, never a pass', verdict({ status: 2, rows: [], unknown: [] }), 2); - check('a new diagnostic is 1 whatever else is known', verdict({ status: 1, rows: [knownRow, newRow], unknown: [newRow] }), 1); - check('rows that are all registered retirements are 0 -- known, not red', verdict({ status: 1, rows: [knownRow], unknown: [] }), 0); - const many = Array.from({ length: 12 }, (_, i) => ({ ...newRow, line: 300 + i })); - check('annotations carry the objectui path in the title, never a file= the platform would re-home', (() => { - const lines = annotationLines([newRow], sha); - return [lines.length, lines[0].startsWith('::error title='), lines[0].includes('objectui packages/react/src/hooks/useViewData.ts%3A200'), lines[0].includes('file=')]; - })(), [1, true, true, false]); - check('annotations stop at the platform cap and say how many more the summary holds', (() => { - const lines = annotationLines(many, sha); - return [lines.length, lines[ANNOTATION_CAP].includes('2 more new diagnostic(s)')]; - })(), [ANNOTATION_CAP + 1, true]); - - fs.rmSync(downstream, { recursive: true, force: true }); - - for (const result of results) { - process.stdout.write(`${result.ok ? 'ok ' : 'FAIL'} ${result.name}\n`); - if (!result.ok) { - process.stdout.write(` expected ${JSON.stringify(result.expected)}\n`); - process.stdout.write(` actual ${JSON.stringify(result.actual)}\n`); - } - } - let failed = results.filter((result) => !result.ok).length; - - // -- The floor, evaluated before the verdict. - const floorFailure = (message) => { - process.stderr.write(`x self-test floor: ${message}\n`); - failed += 1; - }; - const declared = Object.keys(SELF_TEST_BATTERIES); - if (declared.length < SELF_TEST_BATTERY_FLOOR) { - floorFailure(`SELF_TEST_BATTERIES declares ${declared.length} batteries, below the pinned ${SELF_TEST_BATTERY_FLOOR} -- a battery deleted from the roster takes its own floor with it.`); - } - for (const [name, count] of batterySeen) { - if (declared.includes(name)) continue; - floorFailure(`battery "${name}" registered ${count} case(s) but is not declared in SELF_TEST_BATTERIES -- a case attributed to no declared battery is one nothing floors.`); - } - for (const name of declared) { - const count = batterySeen.get(name) ?? 0; - if (count >= SELF_TEST_BATTERIES[name]) continue; - floorFailure( - count === 0 - ? `battery "${name}" DID NOT RUN -- 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned.` - : `battery "${name}" registered ${count} case(s), below its pinned floor of ${SELF_TEST_BATTERIES[name]} -- cases that used to run no longer do; the battery is the bug, not the number.`, - ); - } - - if (failed > 0) { - process.stderr.write(`\nx spec-shape-gate --self-test: ${failed} failure(s) (cases and floor).\n`); - return 1; - } - for (const name of declared) process.stdout.write(` battery ${name}: ${batterySeen.get(name) ?? 0} case(s), floor ${SELF_TEST_BATTERIES[name]}\n`); - process.stdout.write(`spec-shape-gate --self-test: ${results.length}/${results.length} passed across ${declared.length} batteries\n`); - selfTestReachedVerdict = true; - return 0; -} - -/* ─────────────────────────── dispatch ─────────────────────────────────── */ - -function readFlag(argv, flag) { - const at = argv.indexOf(flag); - return at === -1 ? undefined : argv[at + 1]; -} - -async function main(argv) { - if (argv.includes('--self-test')) { - const code = await selfTest(); - if (!selfTestReachedVerdict) { - process.stderr.write( - '\nx spec-shape-gate self-test: selfTest() returned without reaching its verdict, so no success line was printed. ' + - 'Exiting 0 here would report a self-test that never finished as a self-test that passed.\n', - ); - return 1; - } - return code; - } - - const mode = argv[0]; - const sha = readFlag(argv, '--sha'); - const downstream = readFlag(argv, '--downstream'); - - if (mode === 'inject') { - const tarball = readFlag(argv, '--tarball'); - const upstreamCheckout = readFlag(argv, '--upstream-checkout'); - if (!tarball || !sha || !upstreamCheckout || !downstream) { - fail('usage: inject --tarball --sha --upstream-checkout --downstream '); - } - const { substitutions } = inject({ tarball, sha, upstreamCheckout, downstream: path.resolve(downstream), log: (line) => process.stdout.write(`${line}\n`) }); - if (process.env.GITHUB_STEP_SUMMARY) { - const lines = substitutions.length > 0 ? substitutions.map((line) => `- ${line}`) : ["- no dependency substitution: every declared dependency's store sibling satisfies its range."]; - fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `### Spec Shape Gate -- injection\n\n${lines.join('\n')}\n\n`); - } - return 0; - } - - if (mode === 'report') { - const logPath = readFlag(argv, '--log'); - const status = Number(readFlag(argv, '--status') ?? '2'); - if (!logPath || !sha || !downstream) fail('usage: report --log --sha --status --downstream [--downstream-sha ] [--event ] [--registry ]'); - if (!fs.existsSync(logPath)) { - fail(`the captured typecheck log ${logPath} does not exist. With no log there is no reading, and an absent reading is never a pass.`); - } - if (!fs.existsSync(path.join(downstream, 'pnpm-workspace.yaml'))) { - fail(`--downstream ${downstream} holds no pnpm-workspace.yaml, so no diagnostic can be attributed to a workspace directory.`); - } - return report({ - logPath, - sha, - downstreamSha: readFlag(argv, '--downstream-sha') ?? null, - status, - commit: readFlag(argv, '--commit') ?? null, - event: readFlag(argv, '--event') ?? process.env.GITHUB_EVENT_NAME ?? null, - downstream: path.resolve(downstream), - registryPath: readFlag(argv, '--registry') ?? REGISTRY_FILE, - }); - } - - fail(`unknown mode ${JSON.stringify(mode ?? '')}. Modes: inject, report, --self-test.`); - return 2; -} - -if (isEntrypoint(import.meta.url)) { - process.exit(await main(process.argv.slice(2))); -} diff --git a/scripts/ci/spec-shape-retirements.json b/scripts/ci/spec-shape-retirements.json deleted file mode 100644 index db7d1484cd9..00000000000 --- a/scripts/ci/spec-shape-retirements.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$comment": "Intentional spec retirements the Spec Shape Gate reads as `known` instead of red. One entry per retirement, written by the accepting seat when the downstream (objectui) card is filed, deleted when that card closes. Every entry carries exactly: retired, objectstack_pr, objectstack_commit (40-hex merge commit), downstream_card (owner/repo#N), registered_at (YYYY-MM-DD), matches (non-empty; each pattern names a repo-relative objectui `file` -- `*` within a segment, `**` across -- plus at least one of `code` (TS) or `message` (substring)). A file alone is refused. The reader, its grammar and its refusals: scripts/ci/spec-shape-gate.mjs (THE REGISTRY).", - "retirements": [] -} diff --git a/scripts/pm/check-expected-skips.mjs b/scripts/pm/check-expected-skips.mjs index 172476f8f55..f69273ccf76 100644 --- a/scripts/pm/check-expected-skips.mjs +++ b/scripts/pm/check-expected-skips.mjs @@ -234,6 +234,8 @@ const FULL_BATTERY_REASON = * on nine (the tenth head saw no `labeled` / `edited` event after its last * push); `Test Core (…/6)` on the three heads whose diff was outside both the * `core` and the `crosspkg` sets. Zero heads carried any other skipped name. + * `Console Pin Gate`'s row left with its job when the objectui pin retired + * (#25697); the recordings below drop its run the same way. * * ⛔ Adding a row is a declaration that the skip is BY DESIGN, and the row must * name the mechanism — a row added to silence an exit 4 without one is the @@ -328,13 +330,6 @@ export const EXPECTED_SKIPS = Object.freeze([ gate: { kind: 'filter-output', outputs: ['docs'] }, reason: "gated on ci.yml's `filter` job `docs` output (apps/docs, content, the lockfile, ci.yml itself); a diff outside it skips the docs build on the PR head", }, - { - name: 'Console Pin Gate', - workflow: 'ci.yml', - job: 'console-pin', - gate: { kind: 'filter-output', outputs: ['console'] }, - reason: `${FULL_BATTERY_REASON}; on a push it also needs the \`filter\` job's \`console\` output (the \`.objectui-sha\` pin, the console build/probe scripts, and the spec's entry layout: \`packages/spec/package.json\` and \`packages/spec/tsup.config.ts\`)`, - }, { name: 'Type Check UI', workflow: 'ci.yml', @@ -1251,7 +1246,9 @@ function fixtureRun(name, conclusion, suite = 1, extra = {}) { * on one head. Names, conclusions and check-suite ids VERBATIM from the * listing, but for one name: the Test Core matrix template read `/6` then and * is re-spelled `/8` here, the matrix #25376 widened, because the roster - * grades the live name. This is the real shape the roster must accept, kept here so the + * grades the live name; and one run is left out, `Console Pin Gate` (skipped, + * suite 94769267631), whose job retired with the objectui pin (#25697), so 18 + * skipped remain. This is the real shape the roster must accept, kept here so the * measured family and the declared family cannot drift apart unnoticed. */ const MEASURED_18315 = [ @@ -1271,7 +1268,6 @@ const MEASURED_18315 = [ ['Check PR Size', 'skipped', 94771343927], ['Check PR Size', 'skipped', 94906910103], ['Close issues referenced in other repositories', 'success', 94909498693], - ['Console Pin Gate', 'skipped', 94769267631], ['Dogfood Regression Gate', 'success', 94769267631], ['Dogfood Regression Gate (${{ matrix.shard }}/3)', 'skipped', 94769267631], ['Dogfood Verify CLI', 'skipped', 94769267631], @@ -1304,7 +1300,9 @@ function measuredPayload() { * Measured 2026-09-30 on PR #20748's head `a84b73af13` (a `create-objectstack` * landing, so `scaffold-e2e.yml` ran on `pull_request`): 42 check-runs, 34 * success / 8 skipped, full `GET /commits/{head}/check-runs` listing. Names, - * conclusions and check-suite ids VERBATIM. Its one skip outside the listed + * conclusions and check-suite ids VERBATIM but for one run left out, + * `Console Pin Gate` (skipped, suite 99276458806), whose job retired with the + * objectui pin (#25697), so 7 skipped remain. Its one skip outside the listed * roster is `Registry canary: ${{ matrix.template }}` (run 36658032070) — * the raw pre-expansion name the derived half exists to admit. */ @@ -1319,7 +1317,6 @@ const MEASURED_20748 = [ ['Check PR Size', 'skipped', 99276487096], ['Check PR Size', 'success', 99276458818], ['Close issues referenced in other repositories', 'success', 99283715448], - ['Console Pin Gate', 'skipped', 99276458806], ['Dogfood Regression Gate', 'success', 99276458806], ['Dogfood Regression Gate (1/3)', 'success', 99276458806], ['Dogfood Regression Gate (2/3)', 'success', 99276458806], @@ -1418,9 +1415,9 @@ export function selfTest() { const measured = judgeCheckRuns(measuredPayload()); t('measured head: judged (no pending run)', measured.kind, 'judged'); t('measured head: 20 success', measured.success, 20); - t('measured head: 19 skipped, every one expected', measured.expected.reduce((n, e) => n + e.count, 0), 19); + t('measured head: 18 skipped, every one expected', measured.expected.reduce((n, e) => n + e.count, 0), 18); t('measured head: zero unexpected skips', measured.unexpected.length, 0); - t('measured head: eleven of eleven rostered names appear', measured.expected.length, 11); + t('measured head: ten of ten rostered names appear', measured.expected.length, 10); t('measured head: exit 0', verdictExit(measured), 0); t('measured head: the multiplicity is reported (Auto Label ×3)', measured.expected.find((e) => e.name === 'Auto Label')?.count, 3); t('measured head: the report ends on the OK verdict line', renderReport(measured, { head: 'b671f83b', repo: 'objectstack-ai/objectstack' }).at(-1).startsWith('VERDICT check-expected-skips: OK'), true); @@ -1600,7 +1597,7 @@ export function selfTest() { // ---- the measured #20748 head: the card's pins ------------------------------- const m20748 = judgeCheckRuns(measured20748Payload(), live.roster); t('measured #20748: the raw-named skipped canary judges exit 0 against the joined roster', verdictExit(m20748), 0); - t('measured #20748: 8 skipped, every one expected', m20748.expected.reduce((n, e) => n + e.count, 0) === 8 && m20748.unexpected.length === 0, true); + t('measured #20748: 7 skipped, every one expected', m20748.expected.reduce((n, e) => n + e.count, 0) === 7 && m20748.unexpected.length === 0, true); t('measured #20748: …the canary read as a derived expected skip', m20748.expected.find((e) => e.name === CANARY_RAW_NAME)?.derived, true); t('measured #20748: …and the report tags it derived', renderReport(m20748).some((l) => l.includes(CANARY_RAW_NAME) && l.includes('; derived')), true); t('measured #20748: the LISTED half alone still answers exit 4 on it (the derivation is what moved it)', judgeCheckRuns(measured20748Payload()).unexpected[0]?.name, CANARY_RAW_NAME); @@ -1731,7 +1728,7 @@ export function selfTest() { console.log( `✓ check-expected-skips self-test: ${cases.length} cases pass (the exit register; the roster's shape — reasons, no duplicates, ` + 'declared gate kinds — and its truth on this checkout\'s workflows, with the audit driven red on a deleted, renamed, un-gated and ' + - "re-gated job, a lost || 'true' widening and an unreadable workflow; the judge on the measured 39-run head and on fixtures for an " + + "re-gated job, a lost || 'true' widening and an unreadable workflow; the judge on the measured head (38 of its 39 runs) and on fixtures for an " + 'expected skip, an unexpected skip named as a filter miss, a same-suite failure read as a dependency skip, a raw matrix template, ' + 'other conclusions, a pending run and an empty head; the derived half — the non-PR-event recogniser admitting only its one shape and ' + 'refusing a pull_request / push / merge_group term, a negation and every live mixed if:, the raw-name rule, and the derivation ' + diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 8c4225df463..e252ceeb9ec 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -1264,9 +1264,9 @@ export function jobIfReading(ifExpression) { * * `name` is read from the job's own `name:` key at its child indent and falls * back to the job id. It is load-bearing rather than cosmetic: the id - * `console-pin` is not what CI, branch protection or a red check calls that - * job, and a lead a dev cannot find in the Checks tab is a lead they will not - * act on. + * `console-pin` was not what CI, branch protection or a red check called that + * job (`Console Pin Gate`, retired with the objectui pin), and a lead a dev + * cannot find in the Checks tab is a lead they will not act on. */ export function extractJobBlocks(workflowText) { const lines = workflowText.split('\n'); @@ -2083,8 +2083,9 @@ export function defaultedArgv(args, defaults = new Map()) { * `DIRECT_CHECK_INVOCATION` stops the tail before `>`, which is right — the * redirection is the shell's. But `2>&1` puts its fd on the argv side of that * boundary, so `--verbose 2>&1` captured as ` --verbose 2` and the stray `2` - * read as an argument. It is the live spelling in `prerelease-pin-watch.yml`, - * and it is why an argv that was complete all along scored unrenderable. + * read as an argument. It was the live spelling in `prerelease-pin-watch.yml` + * (retired with the objectui pin gates), and it is why an argv that was + * complete all along scored unrenderable. * * The discriminator is the shell's own: digits touching the operator are a * descriptor (`2>`), digits with a space before it are an argument @@ -7698,7 +7699,7 @@ export function coveringTrigger(entry, inputPath) { * what it refuses to follow. * * The JOB NAME travels with the answer because that is what a dev sees go red. - * `console-pin` is the id; `Console Pin Gate` is the required check, and the + * `console-pin` was the id and `Console Pin Gate` the check name, and the * measured failure this closes (#12956) was a dev reading a derived list that * named neither. */ diff --git a/scripts/pm/dispatch-gates.self-test.mjs b/scripts/pm/dispatch-gates.self-test.mjs index 6977bba44ec..a67369fbd90 100644 --- a/scripts/pm/dispatch-gates.self-test.mjs +++ b/scripts/pm/dispatch-gates.self-test.mjs @@ -5760,20 +5760,25 @@ export function selfTest({ tier = 'full' } = {}) { // // Pinned rather than left to the fixture, because the whole finding was that // the FIXTURE-shaped question ("can it follow an indirection?") had a - // different answer from the LIVE one. If the console job is renamed or its - // filter re-spelled, re-point these cases — do not delete them: they are the - // measured statement that a pin bump derives its own gates. + // different answer from the LIVE one. If the job is renamed or its filter + // re-spelled, re-point these cases — do not delete them: they are the + // measured statement that a root file a job filter names derives that job's + // own gates. The card's job, `console-pin` (Console Pin Gate) with the pin + // file `.objectui-sha`, retired with the objectui pin (objectstack#25697); + // they are re-pointed at Build Core, whose `core` filter names root files + // outside every package the same way and whose steps run four dist guards. const liveCiPops = jobPathPopulations(readFileSync(nodePath.join(ROOT, '.github/workflows/ci.yml'), 'utf8'), 'ci.yml'); - const livePinJob = liveCiPops.find((p) => p.checks.includes('check:console-sha')); - t('the live console job is found by the gate it runs', Boolean(livePinJob)); - t('and it is named Console Pin Gate — the name branch protection and the Checks tab use', livePinJob?.name === 'Console Pin Gate'); + const BUILD_CORE_GUARDS = ['check:dts-closure', 'check:dual-build-cjs-loads', 'check:sourcemap-no-sources-content', 'check:lean-entry-closure']; + const liveFilteredJob = liveCiPops.find((p) => p.checks.includes('check:lean-entry-closure')); + t('the live Build Core job is found by the gate it runs', Boolean(liveFilteredJob)); + t('and it is named Build Core — the name branch protection and the Checks tab use', liveFilteredJob?.name === 'Build Core'); t( - 'its derived population covers the pin file, which is the whole acceptance criterion', - Boolean(livePinJob && triggerListCovers(livePinJob.paths, '.objectui-sha')), + 'its derived population covers a root file outside every package (tsconfig.json), which is the whole acceptance criterion', + Boolean(liveFilteredJob && triggerListCovers(liveFilteredJob.paths, 'tsconfig.json')), ); t( - 'both console gates are reached, not just the one the card named', - Boolean(livePinJob?.checks.includes('check:console-sha') && livePinJob?.checks.includes('check:console-injection')), + 'all four of its dist guards are reached, not just the one it was found by', + Boolean(liveFilteredJob && BUILD_CORE_GUARDS.every((c) => liveFilteredJob.checks.includes(c))), ); // The NEGATIVE control, and it is the half that keeps the widening honest: a // path in none of the four filters must derive nothing extra. AGENTS.md is a