diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a808c0bfd22..1556d33ef90 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3791,9 +3791,10 @@ jobs:
pnpm --dir ui run check:bash32-floor
# ── objectui's Repo Gates (objectstack#25880) ────────────────────────
- # The nineteen gates of `ui/.github/workflows/repo-gates.yml`, which
- # GitHub stopped running when the tree moved under `ui/` (it reads only
- # this repository's `.github/workflows/`). They judge what objectui ships
+ # The gates of objectui's `repo-gates.yml`, which GitHub stopped running
+ # when the tree moved under `ui/` (it reads only this repository's
+ # `.github/workflows/`), and which objectstack#25712 deleted with the
+ # rest of `ui/.github/workflows/`. They judge what objectui ships
# — `ui/content/docs/**`, `ui/skills/**`, the package READMEs and the
# documented component types — so the change most likely to trip them is
# a docs-only or skills-only PR, which the `filter` job's `ui` globs
@@ -3808,15 +3809,14 @@ jobs:
# `.objectstack-pin` restore / build / save (the merged tree builds
# `@objectstack/*` from the workspace, see the header above
# `type-check-ui`) and the turbo-cache report / prune bookkeeping (this
- # job's own restore and save serve the build below).
+ # job's own restore and save serve the build below). Retired since: the
+ # Action Ref Convention scan, which judged objectui's own workflows and
+ # went with them (objectstack#25712); a repository-wide action-ref
+ # convention needs a home that no `ui` path filter skips (sub-issue 4).
- name: Control Byte Scan — Scan tracked text files for raw control bytes
if: ${{ !cancelled() }}
run: pnpm --dir ui run check:control-bytes
- - name: Action Ref Convention — Check action reference spelling against the declared convention
- if: ${{ !cancelled() }}
- run: pnpm --dir ui run check:action-ref-convention
-
# Self-test first: a scan whose recogniser is broken reports a clean
# tree. It derives its population from this repository's workflows, so
# it walks this job's install-free steps, itself included.
diff --git a/scripts/check-closing-keyword-parity.mjs b/scripts/check-closing-keyword-parity.mjs
index b6d89e2b167..aea2e3db2c5 100644
--- a/scripts/check-closing-keyword-parity.mjs
+++ b/scripts/check-closing-keyword-parity.mjs
@@ -210,14 +210,9 @@ const NON_PARSERS = [
file: SELF,
why: 'this gate -- it carries the keyword set as its own MUTATION ANCHORS (X4) and as the specimen the sweep self-test plants',
},
- // The three below arrived with the objectui import under ui/. The closer copy
- // IS a parser in its own repository, but GitHub runs workflows only from the
- // root .github/workflows, so here it never executes; the governance fold that
- // deletes ui/.github removes the first row and, with the harness, the third.
- {
- file: 'ui/.github/workflows/cross-repo-issue-closer.yml',
- why: 'the imported objectui copy of the closer -- inert in this repository (GitHub runs only the root .github/workflows), so it closes nothing here; it goes when ui/.github is folded into the root',
- },
+ // The two below arrived with the objectui import under ui/. A third, objectui's
+ // copy of the closer workflow, left with ui/.github/workflows (objectstack#25712);
+ // the harness row goes with the closer's roster row in sub-issue 4.
{
file: 'ui/AGENTS.md',
why: 'the imported objectui agent guide -- it quotes the keyword alternation in prose, warning that the closer ignores negation; it parses nothing',
diff --git a/ui/.github/dependabot.yml b/ui/.github/dependabot.yml
deleted file mode 100644
index e6be54b0d7a..00000000000
--- a/ui/.github/dependabot.yml
+++ /dev/null
@@ -1,68 +0,0 @@
-version: 2
-updates:
- # Enable version updates for npm
- - package-ecosystem: "npm"
- directory: "/"
- schedule:
- interval: "monthly"
- open-pull-requests-limit: 10
- labels:
- - "dependencies"
- - "automated"
- commit-message:
- prefix: "chore"
- include: "scope"
- groups:
- # Group all patch updates together
- patch-updates:
- patterns:
- - "*"
- update-types:
- - "patch"
- # Group dev dependencies
- dev-dependencies:
- dependency-type: "development"
- update-types:
- - "minor"
- - "patch"
- # Group React ecosystem updates
- react:
- patterns:
- - "react*"
- - "@types/react*"
- update-types:
- - "minor"
- - "patch"
- # Group testing libraries
- testing:
- patterns:
- - "*testing*"
- - "vitest*"
- - "@vitest*"
- update-types:
- - "minor"
- - "patch"
-
- # Enable version updates for GitHub Actions. Weekly and grouped into one
- # pull request: the third-party actions that hold a credential are pinned
- # by commit sha, so a release reaches them only through this update.
- - package-ecosystem: "github-actions"
- directory: "/"
- schedule:
- interval: "weekly"
- groups:
- # Majors stay separate pull requests: one declined major (the
- # changesets/action v1 pin is deliberate) must not hold the group.
- github-actions:
- patterns:
- - "*"
- update-types:
- - "minor"
- - "patch"
- labels:
- - "dependencies"
- - "github-actions"
- - "automated"
- commit-message:
- prefix: "chore"
- include: "scope"
diff --git a/ui/.github/workflows/board-snapshot.yml b/ui/.github/workflows/board-snapshot.yml
deleted file mode 100644
index be0bf24e16e..00000000000
--- a/ui/.github/workflows/board-snapshot.yml
+++ /dev/null
@@ -1,493 +0,0 @@
-name: Board Snapshot
-
-# The standing caller for objectstack's `scripts/pm/board-snapshot.mjs`
-# (objectstack#17390), run from an objectstack checkout: this repository holds
-# NO copy of the archiver or of the modules it imports (objectui#10208). The
-# checkout is not pinned to a sha, so each run executes objectstack's `main` as
-# of that run — the same archiver a seat runs from its own objectstack checkout.
-#
-# ## Why a scheduled backup, and why it lands on a branch
-#
-# Three fleet accounts were suspended in two months and the appeals are
-# unanswered. objectstack#17374 F3 measured what a suspension destroys: every issue, pull
-# request and comment the account authored — while every branch and commit
-# survives, because those belong to the repository rather than to a user. The
-# board IS the fleet's state by rule (pm-dispatch SKILL.md, 全体座位的不变量:
-# 「GitHub 之外永不维护任何跟踪状态」), so today one suspension erases state that
-# nothing else holds; six cards, one of them a p1 security decision, are gone.
-#
-# The maintainer's answer, verbatim and untranslated, in chat on 2026-09-10:
-# 「或者用 ci 写个定时任务备份?」 — this file is that scheduled task. It runs as
-# `github-actions[bot]`, which is not a fleet account and cannot be suspended
-# with one, and it writes to an ORPHAN BRANCH of this same repository: no second
-# repo, no second credential, and a branch is precisely the thing a suspension
-# leaves standing.
-#
-# ## An archive is not a tracker — the one-board rule still holds
-#
-# ⛔ Nothing reads `board-archive` for state. Not a seat, not a patrol, not a
-# gate, not a query; every reading of the board still goes to GitHub. The
-# archive answers exactly one question, after a loss: what did the record say?
-# The script it runs has NO write path to GitHub in any mode — two of its
-# self-test cases read its own source and hold that structurally — and
-# `--restore` PRINTS a recreate payload for a seat to post, or for nobody to.
-#
-# ## The budget this run is capped against, and who else spends it
-#
-# `GITHUB_TOKEN` is limited to 1,000 requests per hour PER REPOSITORY. That is
-# ONE pool for every workflow in this repo that calls the API — not a per-workflow
-# allowance — and this job is far from its only caller:
-#
-# this archiver 800 per run (the `--max-requests` cap below), 4/day
-# half-state patrol a board sweep, 4/day at :37 on hours 1,7,13,19
-# merge-queue head patrol HOURLY — four times an hour, at :07, :22, :37, :52
-# required-set patrol daily at 05:23
-# changeset release 4/day at :00 on hours 0,6,12,18
-# link / dist / floor gates 04:11, 04:17, 03:41, and 04:17 on Sundays
-# every CI, lint and smoke run
-# a handful of requests each, hourly and per push
-#
-# ⚠️ That list is THIS repository's, re-derived for the adoption rather than
-# inherited from upstream, and the second row is the one that moved the schedule
-# below. ⛔ It is a reading taken once, not a live count: the instrument that
-# re-derives it is `grep -rn 'cron:' .github/workflows/`.
-#
-# 800 is chosen against that pool rather than against this job's appetite: it is
-# the largest cap that still leaves a fifth of the hour to whatever else lands in
-# the same window, and the schedule below keeps the two heavy board readers out
-# of each other's hours entirely. ⛔ Raising the cap is not a tuning decision,
-# ⛔ there is no retry loop, and ⛔ there is no second token — a run that wants
-# more requests waits for the next slot, which is what the resume cursor is for.
-#
-# ## Why a run may stop before it is finished — and what it reads FIRST
-#
-# A first full snapshot of this board — several thousand numbers, each with a
-# comment thread — does not fit one run and must not try, so the script stops at
-# its own `--max-requests` ceiling, writes its phase cursor into the manifest and
-# exits 0. The next scheduled run continues from that cursor. Four runs a day
-# walk the backlog in a few days without ever exceeding the budget, and a
-# steady-state incremental run costs a few hundred requests.
-#
-# WHICH records it reads first is the part that matters, and it is measured
-# rather than assumed. The first scheduled run of this workflow walked
-# `state=all` oldest-first and spent all 800 requests on 137 closed issues and
-# 268 closed pull requests from February — ZERO open cards, with days of runs
-# still to go. The open board is exactly what a suspension destroys (objectstack#17374 F3),
-# so it was the half being archived last. A first walk now runs the OPEN phase to
-# completion — issues and pull requests, one listing — before the closed history
-# starts, and a run that runs out of budget inside the open set resumes inside
-# it. The manifest's `walk_phase` says which phase a run is in, so "open set
-# complete, history resuming" is never read as "still inside the open set".
-#
-# ⛔ On a real rate-limit refusal the script does NOT retry: it stops, writes the
-# cursor and exits non-zero with the reset time. A loop against a spent budget
-# starves every other automated caller in the repository for the rest of the hour.
-#
-# ## A stale archive is not a green run
-#
-# A run that stops on its own budget is a planned pause and exits 0. A run whose
-# archive has not been CONFIRMED against the live board for longer than a day is
-# a different thing, and until objectstack#18137 it exited 0 as well: NINETEEN consecutive
-# runs of this workflow reported `count_check: pending` between 2026-09-10T20:23Z
-# and 2026-09-14T20:24Z and every one of them is `success` in this workflow's own
-# run history — including 2026-09-14T02:34Z, whose delta cursor was two days
-# behind the live board on the morning an account suspension destroyed cards the
-# archive did not hold. The script now carries a `freshness` row and exits 5 when
-# that streak passes a day; its header is the authority on the anchor and the
-# window, and ⛔ the row prescribes no remedy: the cadence above and the
-# 800-request cap are the maintainer's decisions, not this row's.
-#
-# The ORDER of the steps below is what makes that safe, and it is deliberate: the
-# commit and the R2 mirror run BEFORE the step that reads the exit code, so a red
-# freshness row never costs the records the same run archived. ⛔ Do not move the
-# failing step above them — a stale archive is better than none.
-#
-# ## The `pull_request` trigger, and what it is allowed to do
-#
-# Every patrol in this tree exercises itself on the pull requests that change
-# it. ⚠️ Upstream that is MECHANICAL: `check:pm-dispatch-gates` there pins that
-# no discovered gate family is reachable only through a schedule, and the first
-# draft of the upstream file was refused by exactly that pin for omitting this
-# trigger. ⛔ This repository has no such gate. Here the trigger is a CONVENTION
-# and nothing re-derives it — it is kept because the reason it exists upstream
-# holds here too, and it is named as unenforced rather than left to read as
-# enforced.
-#
-# So a pull_request run DOES execute the snapshot on a real runner — the
-# transport, the flags, the page walk and the rendering, proven where they will
-# actually run — and it writes NOTHING: no archive branch is checked out, the
-# run is `--dry-run` into the runner's temp dir, and the commit step is skipped.
-# It is also capped hard (`--limit`, `--max-requests` below), because the
-# repository's request budget is shared with the half-state patrol and the
-# closed-card sweep and must not be spent per push. ⛔ A pull request must never
-# write to the board OR to the archive.
-#
-# ⚠️ Upstream holds the offline half for free beside it: a `check:*` script runs
-# the archiver's `--self-test` inside the required lint job, which has no paths
-# filter. That is where a change to the archiver is exercised before it merges,
-# because the archiver no longer lives in this repository (objectui#10208); what
-# a change HERE can break is this file's wiring, and this file is the one path
-# below.
-#
-# The step below runs the self-test again before it touches the archive, so a
-# broken tool fails loudly instead of committing a broken backup. ⛔ Its case
-# count is not written down here — the run log prints it.
-#
-# ## Adopting this in a sibling repo
-#
-# Copy this file. It checks the archiver out of objectstack and runs it from
-# there, so no script is copied (objectui#10208).
-#
-# There is nothing to configure. The board archived is `github.repository`,
-# passed explicitly below, and the script REFUSES its resolver's default rather
-# than falling back to one: a copy of this file quietly archiving the repo it was
-# copied FROM would produce a complete, well-formed, green archive of the wrong
-# board.
-
-on:
- schedule:
- # Four times a day, six hours apart. BOTH fields were re-derived against the
- # spender list above — this repository's, not upstream's — rather than
- # inherited with the file (objectui#9387).
- #
- # HOURS — `2,8,14,20`, which is also upstream's set, reached here by the same
- # method rather than copied. The half-state patrol is the other heavy board
- # reader and holds hours 1,7,13,19; both patrol and archiver authenticate as
- # this repository's `GITHUB_TOKEN`, and that budget is per repository per
- # hour, so a run spending its full 800 must not land in a patrol hour. Of the
- # five six-hourly sets that share no hour with 1,7,13,19, this is the only
- # one whose four hours carry no other scheduled workflow at all. The
- # alternatives, on the same inventory: `0,6,12,18` meets the changeset
- # release cron in all four hours and the live E2E run in hour 6; `3,9,15,21`
- # carries the published-dist gate at 03:41 and the shadcn check on Mondays at
- # 09:00; `4,10,16,22` drops an 800-request run into the busiest scheduled
- # hour on this board (04:11 spec-range floors, 04:17 the node-ESM load gate,
- # 04:17 the link check on Sundays); `5,11,17,23` carries the required-set
- # patrol at 05:23.
- #
- # MINUTE — `14`, and this is the field where this repository DIVERGES from
- # upstream's `7`. Upstream's rationale reasons about HOURS and about staying
- # off the top of the hour; it cannot reach a MINUTE collision because
- # upstream has none to reach. This repository runs a merge-queue head patrol
- # EVERY hour at `7,22,37,52`, so `7 2,8,14,20` would start this job in the
- # same minute as that patrol in all four of its hours — the contention
- # upstream's own minute paragraph exists to avoid, one field along. `14` is
- # the midpoint of the first clear window after the top of the hour (:07 to
- # :22), no cron in this repository uses it, and it is early enough that this
- # job's 15-minute timeout cannot spill its requests into the next hour's
- # budget window.
- #
- # The minute stays off the top of the hour on purpose, as in every patrol
- # here: scheduled workflows queue behind everyone else's :00 cron.
- #
- # ⛔ Both paragraphs are a reading of the inventory on the day this landed,
- # not a live count; `grep -rn 'cron:' .github/workflows/` re-derives it.
- #
- # Six-hourly is the loss window this accepts: a card created and destroyed
- # inside one interval was never archived, and nothing cheaper than a webhook
- # closes that, which is a different card.
- - cron: '14 2,8,14,20 * * *'
- workflow_dispatch: {}
- # Changes to the archiver's wiring get exercised before they merge. The run
- # loads nothing else from this repository: the archiver and the modules it
- # imports come from the objectstack checkout below (objectui#10208), so this
- # file is the only path a change here can break it through.
- pull_request:
- paths:
- - '.github/workflows/board-snapshot.yml'
-
-# Least privilege. `contents: write` is for the archive BRANCH and nothing else;
-# the two read scopes are what the snapshot reads. ⛔ This job never writes to
-# `main` and never writes to any issue, pull request, label or comment — there is
-# no scope here that would let it, which is the mechanical half of the one-board
-# rule stated above.
-permissions:
- contents: write
- issues: read
- pull-requests: read
-
-# One archiver at a time. Two runs racing the same branch would have the loser
-# push a snapshot built from a stale checkout, silently discarding the winner's.
-concurrency:
- group: board-snapshot
- cancel-in-progress: false
-
-jobs:
- archive:
- name: Snapshot the board to the archive branch
- runs-on: ubuntu-latest
- timeout-minutes: 15
- steps:
- - name: Checkout repository
- uses: actions/checkout@v7
-
- # The archiver, from objectstack (objectui#10208). A public repository, so
- # the default token reads it; `persist-credentials: false` because nothing
- # here writes to it. Sparse: `scripts/pm` in cone mode also brings the
- # files directly under `scripts/`, which is where the entry-guard helper
- # the archiver and its sweeper module import (`scripts/invoked-as.mjs`)
- # lives.
- - name: Check out the objectstack archiver
- uses: actions/checkout@v7
- with:
- repository: objectstack-ai/objectstack
- path: objectstack-tooling
- sparse-checkout: scripts/pm
- persist-credentials: false
-
- - name: Setup Node.js
- uses: actions/setup-node@v7
- with:
- node-version: '22'
-
- # No `pnpm install`: the archiver imports only `node:` builtins, global
- # `fetch`, and two modules from its own checkout — no npm dependency, so
- # installing the workspace here would buy nothing and would give a
- # scheduled backup a lockfile it could fail on.
- - name: The tool's own cases, before it touches the archive
- run: node objectstack-tooling/scripts/pm/board-snapshot.mjs --self-test
-
- # The archive branch is an ORPHAN: it shares no history with `main`, so a
- # checkout of it carries the archive and nothing else. First run creates it
- # locally; the push below is what publishes it.
- - name: Check out or create the archive branch
- if: github.event_name != 'pull_request'
- run: |
- set -euo pipefail
- git fetch --depth=1 origin '+refs/heads/board-archive:refs/remotes/origin/board-archive' || true
- if git rev-parse --verify --quiet refs/remotes/origin/board-archive > /dev/null; then
- git worktree add archive -b board-archive refs/remotes/origin/board-archive
- echo "archive branch exists; checked out at $(git -C archive rev-parse --short HEAD)"
- else
- git worktree add --detach archive HEAD
- git -C archive checkout --orphan board-archive
- git -C archive rm -rf --quiet .
- # An orphan that still carries main's tree would commit the whole
- # repository into the archive branch on its first run, so this is
- # asserted rather than assumed.
- tracked=$(git -C archive ls-files | wc -l)
- if [ "$tracked" != "0" ]; then
- echo "::error::the new orphan branch still tracks $tracked file(s) from main; refusing to seed the archive with them."
- exit 1
- fi
- echo "archive branch created as an orphan (first run)"
- fi
-
- - name: Run the snapshot
- id: snapshot
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- # WHICH board this run archives: the repo this workflow is installed
- # in, always. A copy of this file cannot end up archiving the repo it
- # was copied FROM, and the script refuses to guess when this is unset.
- PM_SWEEP_REPO: ${{ github.repository }}
- # A pull_request run PROVES the tool on a runner and writes nothing:
- # the temp dir has no manifest, so it is a full read, capped to a
- # handful of numbers and a budget the shared quota will not notice.
- # ⛔ The two spellings differ in WHERE they write and in nothing else,
- # so what a PR exercises is the path a schedule takes.
- SNAPSHOT_ARGS: ${{ github.event_name == 'pull_request' && '--out=board --dry-run --limit=5 --max-requests=25' || '--out=archive/board' }}
- run: |
- set +e
- node objectstack-tooling/scripts/pm/board-snapshot.mjs $SNAPSHOT_ARGS \
- > "$RUNNER_TEMP/snapshot.md" 2> "$RUNNER_TEMP/snapshot.err"
- code=$?
- set -e
- # Captured with NO pipe in between. `cmd | tail` reports the PIPE's
- # status — `tail` essentially never fails, so a clean snapshot and a
- # failed count check would both read as 0, and this script's exit
- # register (0 / 2 / 3 / 4) is the whole point of having one.
- echo "exit_code=$code" >> "$GITHUB_OUTPUT"
- echo "board-snapshot exited $code"
- cat "$RUNNER_TEMP/snapshot.md" || true
- cat "$RUNNER_TEMP/snapshot.err" >&2 || true
-
- # Land the truth, THEN raise the alarm: whatever the snapshot managed to
- # read is committed before the run is allowed to go red, so a failing count
- # check never costs the records the same run archived.
- - name: Commit and push the archive
- id: commit
- if: github.event_name != 'pull_request'
- run: |
- set -euo pipefail
- if [ -z "$(git -C archive status --porcelain)" ]; then
- echo "committed=none" >> "$GITHUB_OUTPUT"
- echo "no change on the board since the last run — nothing committed."
- exit 0
- fi
- git -C archive add -A
- files=$(git -C archive diff --cached --name-only | wc -l)
- git -C archive \
- -c user.name='github-actions[bot]' \
- -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \
- commit -q -m "board snapshot $(date -u +%Y-%m-%dT%H:%M:%SZ)" \
- -m "run ${{ github.run_id }} · ${files} file(s) changed"
- git -C archive push origin board-archive
- echo "committed=${files}" >> "$GITHUB_OUTPUT"
- echo "pushed ${files} changed file(s) to board-archive"
-
- # ## The copy that outlives the platform, not just the account
- #
- # The archive branch above survives an ACCOUNT suspension, because a
- # branch belongs to the repository rather than to a user. It does not
- # survive an action against the organisation or the repository itself,
- # and that is the remaining hole. The maintainer's answer, verbatim and
- # untranslated, in chat on 2026-09-13:
- #
- # 「那如果推 s3 呢?」 「Cloudflare R2」
- #
- # So this step mirrors the archive CHECKOUT — the same tree the commit
- # above just pushed — into one R2 prefix. Two things follow from R2
- # having NO bucket versioning:
- #
- # - `--delete` makes the prefix equal to the branch tip and nothing
- # else, so a restore never reads a file the board no longer has;
- # - a mirror therefore keeps no HISTORY at all, so once per UTC day the
- # tree is also written as `snapshots/YYYY-MM-DD.tar.gz`. That key
- # lives OUTSIDE the mirrored prefix on purpose: inside it, the very
- # next `--delete` sync would remove it as an object with no local
- # counterpart, and the history would be exactly one day long.
- #
- # The daily tarball is decided by asking the bucket for today's key
- # rather than by the clock, so a missed 02:07 run, a re-run and a
- # `workflow_dispatch` all converge on one tarball per day.
- #
- # ## Unset secrets are a notice, not a failure
- #
- # The bucket and its scoped API token are the maintainer's to create.
- # Until all four secrets exist this step prints one notice and exits 0,
- # so this workflow keeps archiving to the branch and nothing here has to
- # be merged in the same hour as the bucket. A CONFIGURED upload that then
- # fails is the opposite case and goes red: a backup that silently stops
- # copying is the failure mode this whole file exists to prevent.
- #
- # ⛔ The four secrets are read, never echoed, and never written to the
- # summary; the run below also never touches the board, `main`, or the
- # archive branch — it only reads the checkout that is already on disk.
- - name: Upload the archive to R2
- id: r2
- if: github.event_name != 'pull_request'
- env:
- AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
- AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
- # R2 is S3-compatible with one fixed pseudo-region.
- AWS_DEFAULT_REGION: auto
- # S3-compatible endpoints other than S3 itself are the documented
- # case for this setting: the CLI then sends an integrity checksum
- # only where the API requires one, instead of on every request.
- AWS_REQUEST_CHECKSUM_CALCULATION: when_required
- R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
- R2_BUCKET: ${{ secrets.R2_BUCKET }}
- run: |
- set -euo pipefail
-
- if [ -z "${AWS_ACCESS_KEY_ID:-}" ] || [ -z "${AWS_SECRET_ACCESS_KEY:-}" ] \
- || [ -z "${R2_ACCOUNT_ID:-}" ] || [ -z "${R2_BUCKET:-}" ]; then
- echo "status=skipped — no R2 credentials configured in this repository" >> "$GITHUB_OUTPUT"
- echo "::notice::R2 upload skipped: R2_ACCOUNT_ID, R2_BUCKET, R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY are not all set as repository secrets; this run is archived on the board-archive branch only."
- exit 0
- fi
-
- endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
- # ⭐ The prefix segment is THIS repository's name, and it is the one
- # place the R2 step could not be adopted verbatim. Upstream hard-codes
- # `objectstack/` here and in the tarball key below, while everything
- # else about which board is archived is passed in (`PM_SWEEP_REPO`
- # above). Two repositories pointed at one bucket with upstream's
- # literal would share one `--delete` sync target, so each run would
- # delete the other board's objects and the daily tarballs would
- # alternate between two boards under one key — a complete,
- # well-formed, green archive of a board that is half missing, which is
- # the same footgun the header's adopt section warns about, one layer
- # out. ⛔ Do not restore the upstream literal.
- mirror="s3://${R2_BUCKET}/objectui/board/"
-
- # The SOURCE is the snapshot directory, NOT the worktree root that
- # contains it. The snapshot above is written to `archive/board`, so
- # syncing `archive/` would map `archive/board/X` onto the `mirror`
- # prefix as `objectui/board/` + `board/X` — one segment deeper
- # than the prefix on the line above says the mirror is, and visible
- # only from the bucket side. Syncing `archive/board/` maps
- # `archive/board/X` to `objectui/board/X`, which is the layout a
- # restore driven from the documented path looks for.
- #
- # `--delete` needs no migration step here: this prefix has never been
- # written, so the first configured run seeds it. (Upstream carries a
- # note about a stale doubled tree its own earlier layout left behind;
- # that history is not this repository's.) ⛔ The target is never
- # widened to `objectui/` to achieve the same mapping — that would put
- # `objectui/snapshots/` inside a `--delete` target and eat the daily
- # tarballs, the failure the comment above already warns about.
- #
- # No `.git` filter is needed here: `archive/` is a linked worktree,
- # so its `.git` is a FILE at `archive/.git`, which is outside this
- # source (filters match paths relative to the source directory).
- # No pipe between the command and `$?` — see the snapshot step.
- set +e
- aws s3 sync archive/board/ "$mirror" \
- --endpoint-url "$endpoint" \
- --delete --no-progress \
- > "$RUNNER_TEMP/r2-sync.log" 2>&1
- code=$?
- set -e
- if [ "$code" != "0" ]; then
- cat "$RUNNER_TEMP/r2-sync.log" >&2
- echo "status=FAILED — aws s3 sync exited $code" >> "$GITHUB_OUTPUT"
- echo "::error::aws s3 sync to R2 exited $code. This run IS archived on board-archive; the out-of-GitHub copy is now behind and every later run will stay behind until this is fixed."
- exit 1
- fi
- # One line per object moved. The first configured run moves the whole
- # board and every later run moves a handful, so the log is truncated
- # here and the counts below are taken over all of it.
- transfers=$(wc -l < "$RUNNER_TEMP/r2-sync.log")
- head -n 50 "$RUNNER_TEMP/r2-sync.log"
- if [ "$transfers" -gt 50 ]; then
- echo "... and $((transfers - 50)) further transfer line(s), not printed."
- fi
- uploaded=$(grep -c '^upload:' "$RUNNER_TEMP/r2-sync.log" || true)
- deleted=$(grep -c '^delete:' "$RUNNER_TEMP/r2-sync.log" || true)
-
- day=$(date -u +%Y-%m-%d)
- tarball="s3://${R2_BUCKET}/objectui/snapshots/${day}.tar.gz"
- if aws s3 ls "$tarball" --endpoint-url "$endpoint" > /dev/null 2>&1; then
- tarred="already written for ${day}"
- else
- tar -czf "$RUNNER_TEMP/${day}.tar.gz" --exclude=.git -C archive .
- aws s3 cp "$RUNNER_TEMP/${day}.tar.gz" "$tarball" \
- --endpoint-url "$endpoint" --no-progress
- tarred="written for ${day}"
- fi
-
- echo "status=synced ${uploaded} object(s), deleted ${deleted}; daily tarball ${tarred}" >> "$GITHUB_OUTPUT"
- echo "R2: synced ${uploaded} object(s), deleted ${deleted}; daily tarball ${tarred}"
-
- - name: Publish the run to the summary
- if: always()
- run: |
- {
- echo "### Board snapshot — exit ${{ steps.snapshot.outputs.exit_code }}"
- echo
- echo "Committed: ${{ steps.commit.outputs.committed || 'nothing (the run stopped before the commit step)' }}"
- echo
- echo "R2: ${{ steps.r2.outputs.status || 'not attempted (a pull_request run, or the job stopped before the upload step)' }}"
- echo
- echo '```'
- cat "$RUNNER_TEMP/snapshot.md" 2>/dev/null || echo '(no report produced)'
- echo '```'
- echo
- echo 'stderr
'
- echo
- echo '```'
- cat "$RUNNER_TEMP/snapshot.err" 2>/dev/null || true
- echo '```'
- echo
- echo ' '
- } >> "$GITHUB_STEP_SUMMARY"
-
- # LAST on purpose: every step that lands the records is above it, so this
- # one only ever decides the colour of the run. See the header.
- - name: Fail the run if the snapshot did not read the board, or the archive is behind it
- if: steps.snapshot.outputs.exit_code != '0'
- run: |
- echo "::error::board-snapshot exited ${{ steps.snapshot.outputs.exit_code }} — 2 is a failed count check (the archive and the board disagree about how many open issues exist), 3 is a prerequisite the runner did not meet, 4 is a rate-limit stop with a resume cursor written, 5 is a STALE archive (no run has confirmed it against the live board for longer than a day; the run summary names the streak and what clears it). Whatever was read HAS been committed; see this run's summary."
- exit 1
diff --git a/ui/.github/workflows/changelog.yml b/ui/.github/workflows/changelog.yml
deleted file mode 100644
index 7f0dd7bdcf9..00000000000
--- a/ui/.github/workflows/changelog.yml
+++ /dev/null
@@ -1,116 +0,0 @@
-name: Auto Changelog
-
-# Dispatch-only, deliberately (objectui#5409).
-#
-# This lane also declared `release: types: [published]` until 2026-08-23. That
-# trigger was dead configuration, not a fallback: every release in this
-# repository is authored by `github-actions[bot]`, created by the Changesets
-# action in `changeset-release.yml` using `secrets.GITHUB_TOKEN`, and GitHub
-# does not start workflow runs from events raised with that token (the
-# recursive-trigger guard). Measured before removal: this workflow had 0 runs
-# across the repository's whole life, against `changeset-release.yml`'s 4049
-# through the identical API call — so the zero was the trigger, not the query.
-#
-# When to run it: at release time, as part of cutting the release. Nothing else
-# invokes this workflow. The root CHANGELOG.md is a periodically hand-curated
-# summary; the per-package CHANGELOG.md files that Changesets writes on the
-# release commit are the source of truth for granular and current history.
-on:
- workflow_dispatch:
-
-permissions:
- contents: write
- pull-requests: read
-
-jobs:
- changelog:
- runs-on: ubuntu-latest
-
- # ── NO job ceiling, and that is the DECISION, not its absence (objectui#7956) ──
- # This job runs under GitHub's 360-minute default. objectui#7956 asked for a
- # ceiling DERIVED from this job's own measured run-time distribution, in the
- # shape objectui#7270 left beside `lint.yml::lint`. It is recorded here
- # instead, because this job has no distribution to derive one from.
- #
- # - Population: every run of this workflow, no status filter, whole
- # retained history — `GET /actions/workflows/changelog.yml/runs` reports
- # `total_count: 0`. Re-measured 2026-09-06T17:22Z.
- # - Control, so that zero is a reading rather than a broken query: the
- # identical call against `changeset-release.yml` reports a `total_count`
- # of 5010 completed runs. The endpoint answers; this workflow has never
- # produced a run for it to answer with.
- # - The header above already recorded this same zero once, measured before
- # 2026-08-23 against the same endpoint while removing the dead `release`
- # trigger. It has not moved: this workflow is dispatch-only, and it has
- # never been dispatched.
- #
- # ⛔ Do NOT put a number here derived from another job, from the apparent
- # cost of a checkout plus git-cliff, or from what these steps "should" take.
- # A ceiling set under a job's honest slowest run converts a working job into
- # a permanently red one (objectui#7048), and this job has produced no honest
- # run to sit above; objectstack#16173 is the live counter-example, where a
- # distribution mis-estimated by ~2.6x killed a test shard while the rollup
- # read green. An invented ceiling is worse than the default it replaces,
- # because it looks derived.
- #
- # ⚠️ The accepted cost, stated plainly: a hang in a dispatched run occupies
- # a runner for six hours before reporting `cancelled`. That is the same
- # exposure objectui#7956 closed on four of its six jobs. It is accepted here
- # only because the alternative is a guess, and it is bounded in practice by
- # the fact that nothing starts this workflow except a maintainer, who is by
- # construction watching when they do.
- #
- # What would change this: one real dispatch. Measure that run the way
- # `lint.yml::lint` documents, write the derivation here, and move this job
- # out of the accept-360 table and into the `derived` table in
- # `scripts/__tests__/workflow-cache-save-bound.test.ts` in the same commit —
- # that pin holds this job's UNBOUNDED state deliberately, so adding a
- # ceiling has to be an edit someone makes on purpose rather than a silent
- # one.
- steps:
- - name: Checkout code
- uses: actions/checkout@v7
- with:
- fetch-depth: 0
- submodules: true
-
- # No lockfile merge driver here, deliberately (objectui#6358).
- #
- # This job configured one until 2026-08-25. A merge driver is invoked by
- # git only when git actually has to MERGE the attributed path, and this
- # job never merges: it checks out, runs git-cliff, stages exactly
- # CHANGELOG.md, commits, and pushes. There is no merge, rebase, pull,
- # cherry-pick or revert in it, and it never touches `pnpm-lock.yaml` at
- # all. The stated reason for the step -- that it "commits back to a branch
- # that may have moved" -- does not reach a driver: a push to a branch that
- # moved is REJECTED, not merged, and nothing here resolves that rejection.
- #
- # So the step was dead configuration rather than a missing-toolchain bug.
- # Note the disposition did NOT turn on the workflow being unreachable: it
- # is `workflow_dispatch`, a maintainer can start it any time, and it is
- # meant to be run at release time. It had 0 runs when this was measured,
- # but "never dispatched" is not "cannot run" -- the driver would not fire
- # even on a successful manual dispatch, which is the reason acted on.
- #
- # Restoring it needs a real merge in this job first, not the instinct that
- # every workflow touching git wants the driver. If one is ever added, add
- # `corepack enable` + `actions/setup-node` ahead of it the way
- # `changeset-release.yml` and `dependabot-auto-merge.yml` do -- the driver
- # shells out to pnpm, and this workflow pins no toolchain.
-
- - name: Generate changelog
- uses: orhun/git-cliff-action@a9a95522b26fe6403f7bb24031f21fb573d0f5ff # v4.9.1
- id: changelog
- with:
- config: cliff.toml
- args: --verbose --latest
- env:
- OUTPUT: CHANGELOG.md
-
- - name: Commit changelog
- run: |
- git config --local user.email "github-actions[bot]@users.noreply.github.com"
- git config --local user.name "github-actions[bot]"
- git add CHANGELOG.md
- git commit -m "docs: update CHANGELOG.md" || echo "No changes to commit"
- git push
diff --git a/ui/.github/workflows/changeset-guard.yml b/ui/.github/workflows/changeset-guard.yml
deleted file mode 100644
index 35ca7279915..00000000000
--- a/ui/.github/workflows/changeset-guard.yml
+++ /dev/null
@@ -1,162 +0,0 @@
-name: Changeset Guard
-
-# Why this is its own workflow instead of a job in `ci.yml`: on a PR that adds
-# only a changeset, every gate inside `ci.yml` and `lint.yml` skips, so nothing
-# in either of them ever reads the changeset — which is exactly the PR this check
-# needs to see. It has to run outside that decision.
-#
-# That is no longer the reason this header used to give. It said both `ci.yml`
-# and `lint.yml` list `'**/*.md'` AND `.changeset/**` under `paths-ignore`, so a
-# changeset-only PR "starts no workflow at all". objectui#3523 step 2 deleted
-# `paths-ignore` from their `pull_request` trigger; it remains ONLY on `push`.
-# Such a PR does start both workflows and does produce their contexts — measured:
-# PR #3856 (one markdown file) 16 checks, PR #4339 (one line added to AGENTS.md)
-# 17. The correction is objectui#3857; an author had already acted on the old
-# sentence and got the opposite result.
-#
-# What #3523 moved rather than deleted is the path DECISION: it is now the
-# `Decide whether this change needs a full run` step in `ci.yml`, with a twin in
-# `lint.yml`, and its exclusion list is that `push` filter unchanged —
-# `'**/*.md'` and `.changeset/**` included, held identical to it by
-# `scripts/__tests__/merge-queue-reporting.test.ts`. Both workflows therefore
-# start, report, and skip every expensive step on precisely this PR. GitHub has
-# no per-job path filter either, so the gate lives here, with the inverse
-# trigger: it runs *only* when `.changeset/**` changes.
-#
-# It needs no install and no build — a checkout plus a `node` call per job, a
-# few seconds — so keep it that way if you add checks to it.
-#
-# ── Two jobs, opposite questions ─────────────────────────────────────────────
-#
-# `no-major` — the LEVEL a pending changeset declares. Reads the tree.
-# `overwrite` — whether this change touched a changeset it did not ADD. Reads
-# the diff against the merge base, so it alone needs
-# `fetch-depth: 0` (objectui#6336). Report-only; the script's
-# header carries the history that chose that over blocking.
-#
-# ── Self-coverage (objectui#6321) ────────────────────────────────────────────
-#
-# The `.changeset/**` entry above is the inverse trigger this file exists for;
-# the two entries below it are a DIFFERENT thing — ordinary self-coverage, so a
-# change to the gate itself is exercised by the PR that makes it rather than by
-# the next unrelated PR that happens to touch `.changeset/**`. Before this,
-# neither this YAML nor the script it runs was in the filter at all: measured
-# against the other six path-filtered workflows in this repo, 5 of 7
-# self-include and this was one of the two that did not (the other,
-# `performance-budget.yml`, was closed in #6315).
-#
-# The convention those five follow is "list what the `run:` step below actually
-# executes" — own YAML plus the invoked scripts, both of them — not the
-# transitive import graph. `half-state-patrol.yml` additionally lists
-# `scripts/invoked-as.mjs`,
-# but that is a documented one-off (its own header's divergence #2, tied to
-# objectui#5791's port from objectstack) and NOT the pattern the other three
-# self-including, script-running gates follow: `node-esm-load-gate.yml`,
-# `published-dist-gate.yml` and `spec-range-floors.yml` all import
-# `scripts/invoked-as.mjs` too (same `isEntrypoint` helper) and none of them
-# lists it — it is a widely shared utility (40+ importers under `scripts/`),
-# and a change to it is already caught by `check-entry-guard.mjs` /
-# `entry-guard-wiring.test.ts` in the ordinary vitest suite, not by re-running
-# every gate that happens to import it. So this filter matches the
-# three-of-four majority and does not add it either.
-#
-# Deliberately NOT listed for the same reason: `scripts/check-changeset-presence.mjs`.
-# `check-changeset-overwrite.mjs` imports its base-ref resolver, its `git diff`
-# wrapper and its frontmatter reader rather than growing a THIRD copy of them —
-# the second copy, in `check-i18n-en-drift.mjs`, inherited a real defect from the
-# first draft of that resolver and had to be fixed to match under objectui#3766,
-# which is the drift a third copy would reopen. It is an import, not something a
-# `run:` step executes, and a change to it is already caught by the root vitest
-# suite (`check-changeset-presence.test.ts` and `check-changeset-overwrite.test.ts`
-# both exercise it) on any pull request touching `scripts/**` — the same
-# `~ partial` reasoning applied to `invoked-as.mjs` just above.
-#
-# Also deliberately NOT listed: `scripts/__tests__/check-changeset-no-major.test.ts`
-# and `scripts/__tests__/check-changeset-overwrite.test.ts`.
-# That file matches the `~ partial` pattern `published-dist-gate.yml` and
-# `spec-range-floors.yml` already set for their own gate scripts' `__tests__`
-# files — the test lives under `scripts/**`, which the `Decide whether this
-# change needs a full run` step in `ci.yml`/`lint.yml` does NOT exclude, so any
-# PR that edits it (or the script) already gets a real run of the root vitest
-# suite that executes it. Adding it here would duplicate coverage `pnpm test`
-# already provides on exactly the PRs where it matters, for no PR it would add
-# it on.
-
-on:
- pull_request:
- branches: [main, develop]
- paths:
- - '.changeset/**'
- - '.github/workflows/changeset-guard.yml'
- - 'scripts/check-changeset-no-major.mjs'
- - 'scripts/check-changeset-overwrite.mjs'
- push:
- branches: [main]
- paths:
- - '.changeset/**'
- - '.github/workflows/changeset-guard.yml'
- - 'scripts/check-changeset-no-major.mjs'
- - 'scripts/check-changeset-overwrite.mjs'
-
-concurrency:
- group: changeset-guard-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
-
-permissions:
- contents: read
-
-jobs:
- no-major:
- name: Changeset Bump Policy
- runs-on: ubuntu-latest
- timeout-minutes: 5
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v7
-
- # One `major` in the 39-package `fixed` group publishes all 39 as the next
- # major, taking objectui off the `@objectstack` major it is pinned to.
- # See AGENTS.md §版本号策略, and the script's header for the full rationale.
- - name: Verify no changeset declares a major bump
- run: node scripts/check-changeset-no-major.mjs
-
- # A SECOND job rather than a step in `no-major`, for one reason: this gate
- # reads a diff and therefore needs `fetch-depth: 0`, and `no-major` reads the
- # tree and wants to stay a depth-1 checkout. Two jobs also run in parallel,
- # so the workflow is no slower than its slowest half.
- overwrite:
- name: Changeset Overwrite Report
- runs-on: ubuntu-latest
- timeout-minutes: 5
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v7
- with:
- # The gate compares this change against its MERGE BASE with the target
- # branch, so it needs history — checkout's default is a depth-1 clone
- # where `git merge-base` has nothing to find. An unresolvable base is a
- # hard failure in the script, never a skip, so getting this wrong is a
- # red build rather than a silent pass; spelled out here so it stays
- # that way. Same requirement, same reason, as `changeset-presence.yml`.
- fetch-depth: 0
-
- - name: Setup Node.js
- uses: actions/setup-node@v7
- with:
- node-version: '22.x'
-
- # REPORT-ONLY today, and that is a measured choice rather than caution:
- # all 19 modifications of a pre-existing changeset in this repository's
- # history were legitimate (bump-level corrections, prose corrections,
- # authors amending their own unreleased changeset), so a blocking gate
- # would have failed every one of those pull requests. What it catches is
- # the overwrite in objectui#6336, whose cost lands on a THIRD PARTY — the
- # earlier pull request whose release declaration disappears — and is
- # invisible when it happens: `git status` shows ` M`, not `??`. The step
- # prints the declaration that was there and what is now gone from it.
- # `OS_CHANGESET_OVERWRITE_ENFORCE=1` flips it to blocking for whoever
- # revisits this with a new measurement.
- - name: Report changesets this change did not add
- run: node scripts/check-changeset-overwrite.mjs
diff --git a/ui/.github/workflows/changeset-presence.yml b/ui/.github/workflows/changeset-presence.yml
deleted file mode 100644
index cb79f55d5d4..00000000000
--- a/ui/.github/workflows/changeset-presence.yml
+++ /dev/null
@@ -1,309 +0,0 @@
-name: Changeset Presence
-
-# Demands the DECLARATION that objectstack#4731 / #4843 made the criterion for
-# "which frontend changes shipped": a change to the source of a package the
-# release covers must add a `.changeset/*.md`. An empty frontmatter counts — what
-# is required is one sentence written while the author still knows what the change
-# does, not a release. Full rationale, the measured history, and the exemption's
-# exact spelling: `scripts/check-changeset-presence.mjs`.
-#
-# Why this is a SECOND changeset workflow rather than a wider trigger on the
-# first. `changeset-guard.yml` runs only when `.changeset/**` changes, and that
-# inversion is deliberate and documented in its own header: on a PR that adds
-# ONLY a changeset, every gate inside `ci.yml` and `lint.yml` skips, so nothing
-# in either of them ever reads the changeset — and that guard exists to see
-# exactly that PR. A PR which FORGOT its changeset does not touch `.changeset/**`
-# at all, so the one check that could notice is the one guaranteed not to run.
-# Widening those paths would break the case it was built for. Hence two
-# workflows, opposite directions: that one polices the level of a declaration
-# that exists, this one polices the existence of a declaration at all.
-#
-# That is no longer the reason this header used to give. It said `ci.yml` and
-# `lint.yml` both list `.changeset/**` under `paths-ignore`, so a PR adding ONLY
-# a changeset "starts nothing else". objectui#3523 step 2 deleted `paths-ignore`
-# from their `pull_request` trigger; it remains ONLY on `push`. Such a PR does
-# start both workflows and does produce their contexts — measured: PR #3856 (one
-# markdown file) 16 checks, PR #4339 (one line added to AGENTS.md) 17. The
-# correction is objectui#3857; an author had already acted on the old sentence
-# and got the opposite result.
-#
-# What #3523 moved rather than deleted is the path DECISION: it is now the
-# `Decide whether this change needs a full run` step in `ci.yml`, with a twin in
-# `lint.yml`, and its exclusion list is that `push` filter unchanged — markdown
-# and `.changeset/**` included, held identical to it by
-# `scripts/__tests__/merge-queue-reporting.test.ts`. Both workflows therefore
-# start, report, and skip every expensive step on precisely this PR: the
-# conclusion survives its premise, and `changeset-guard.yml`, running outside
-# that in-job switch with no install and no build, is still the only thing that
-# judges a changeset-only PR at all.
-#
-# Hence also: no `paths` and no `paths-ignore` here, deliberately — the same
-# choice `control-bytes.yml` and `docs-links.yml` made and for a stronger reason.
-# A path filter on the trigger skips the WHOLE workflow (GitHub has no per-job
-# path filter), so the context is never CREATED on a pull request that does not
-# match, and a required context that is never created leaves the PR pending rather
-# than failing it — in the queue, until the ruleset's 60-minute timeout. That is
-# objectui#3523's second half, and it is why the four gates moved their filters
-# into the jobs. This gate reports on every pull request instead: it decides from
-# the diff, inside the script, and says so when nothing is owed. A `paths` filter
-# would additionally be a second copy of the script's guarded surface, free to
-# drift from it — and the surface is derived from `.changeset/config.json`
-# precisely so there is only one. `scripts/__tests__/check-changeset-presence.test.ts`
-# fails if a filter is ever added.
-#
-# It needs no install and no build — a checkout, `setup-node`, and one `node` call
-# over `git diff` — so keep it that way if you add checks to it.
-
-on:
- pull_request:
- branches: [main, develop]
- # Merge queue (objectui#3523 — see `ci.yml`'s trigger block for the full note
- # and the measurements behind it). A required context that does not report on a
- # queue build stalls the queue until the ruleset's 60-minute status-check
- # timeout fails it, so a gate that carries no path filter — and therefore CAN
- # be required — has to subscribe. `types:` is named although `checks_requested`
- # is currently the only one GitHub defines.
- #
- # The script needs no per-event branch to work here: it resolves the base as
- # the merge base with the target branch, and on a queue build (no
- # `GITHUB_BASE_REF`, no `github.event.pull_request`) that falls through to the
- # merge base with `origin/main`, which is the commit the queue built the group
- # on. `ci.yml`'s `pnpm check:i18n-drift` step already resolves its base exactly
- # this way on this event.
- merge_group:
- types: [checks_requested]
-
-# Deliberately NOT subscribed:
-#
-# - `push` to `main`. There is nothing left to demand: the change has landed,
-# and failing the push would only paint `main` red at the author of the next
-# commit. The pull request and the queue build are where a declaration can
-# still be written.
-# - `workflow_dispatch`. A manual run has no revision range to judge, and this
-# gate fails loudly rather than inventing one. Locally it is
-# `node scripts/check-changeset-presence.mjs`, which defaults to this branch
-# against its merge base and reads the working tree, so an author gets the
-# answer before committing.
-
-concurrency:
- group: changeset-presence-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
-
-permissions:
- contents: read
-
-jobs:
- changeset-presence:
- name: Changeset Declaration
- runs-on: ubuntu-latest
- timeout-minutes: 5
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v7
- with:
- # The gate compares this change against its MERGE BASE with the target
- # branch, so it needs history — checkout's default is a depth-1 clone
- # where `git merge-base` has nothing to find. An unresolvable base is a
- # hard failure in the script, never a skip, so getting this wrong is a
- # red build rather than a silent pass; it is spelled out here so it
- # stays that way. Same requirement, same reason, as the `fetch-depth: 0`
- # on `ci.yml`'s `type-check` job for `pnpm check:i18n-drift`.
- fetch-depth: 0
-
- - name: Setup Node.js
- uses: actions/setup-node@v7
- with:
- node-version: '22.x'
-
- # Reads `.changeset/config.json` and `git diff`, and nothing else — no
- # install, no network. The guarded surface is every workspace package named
- # in the `fixed` group, so it follows the release configuration instead of
- # being a hand-written glob: `@object-ui/console` lives at `apps/console`,
- # outside `packages/`, and is both the most-edited published package here
- # and the one the platform's `bump-objectui.sh` writes a changeset for.
- - name: Verify a changeset declares this change
- run: node scripts/check-changeset-presence.mjs
-
- # A SECOND job rather than a step in `changeset-presence`, for two reasons.
- # It answers the OPPOSITE question — `changeset-presence` asks whether THIS
- # change declares one, this one asks whether SOMEBODY ELSE'S pending
- # declaration still describes the tree — and it is report-only, so a finding
- # must never turn the declaration gate's context red.
- #
- # Why it lives in THIS workflow and not in `changeset-guard.yml` with the
- # other two changeset gates: that workflow carries a `paths:` filter. For the
- # overwrite gate the filter is free, because a change that modifies a
- # changeset touches `.changeset/**` by definition. This gate's subject is the
- # opposite — an ordinary source change that falsifies somebody else's pending
- # claim — and nothing obliges it to touch `.changeset/**` at all. This
- # workflow has no path filter, which is exactly what that needs; its absence
- # is pinned in `scripts/__tests__/check-changeset-claims.test.ts`.
- changeset-claims:
- name: Changeset Claim Re-read
- runs-on: ubuntu-latest
- timeout-minutes: 5
-
- # ⚠️ A JOB-LEVEL block, so `pull-requests: write` reaches THIS job and not
- # the declaration gate above it, which needs nothing but a checkout. A
- # job-level block REPLACES the workflow-level one rather than extending it,
- # so `contents: read` has to be named again here or checkout loses it.
- #
- # ⛔ Not a new permission (objectui#9140's ruling required this to be
- # checked): `pull-requests: write` on the run's `GITHUB_TOKEN` is what
- # `performance-budget.yml` already uses to post the Console Performance
- # Budget report, and four other workflows here hold it too. There is no new
- # secret, no personal token, no app, and no `pull_request_target`.
- permissions:
- contents: read
- pull-requests: write
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v7
- with:
- # Same requirement, same reason, as the job above: the gate compares
- # this change against its MERGE BASE, and an unresolvable base is a
- # hard failure in the script rather than a skip.
- fetch-depth: 0
-
- - name: Setup Node.js
- uses: actions/setup-node@v7
- with:
- node-version: '22.x'
-
- # REPORT-ONLY, with no enforcing switch at all, deliberately: "a pending
- # changeset names a file you edited" is usually still TRUE, and a gate
- # that failed a build on prose being adjacent is the one triage said
- # would be turned off within a month. It reads a tree listing and one
- # batched blob read — measured at ~1.1s against the 1,384 changesets
- # pending when it was written — so it needs no install and no build.
- - name: Report pending changesets this change may have falsified
- # `--json` is the hand-off to the delivery steps below, written from the
- # same run that prints the log — ⛔ never a second measurement, which
- # could disagree with the log it claims to report.
- run: node scripts/check-changeset-claims.mjs --json claims.json
-
- # ── Delivery (objectui#9140, director ruling, maintainer 「同意」) ──────
- #
- # The finding is a REQUEST TO READ, and objectui#9140 measured that request
- # at zero answers out of four because it was addressed to a job log nobody
- # opens on a green check. So it is carried to the pull request, where the
- # seat that can answer it already is.
- #
- # ⛔ Report-only is UNCHANGED and must stay that way: the step above still
- # exits 0 on findings, this job is still not a required context, and the
- # rendered comment says both in its own opening lines. Delivery moves where
- # the finding is READ, never what it does.
- #
- # Rendering lives in a script for the reason the budget report's does:
- # logic embedded in YAML cannot be unit tested, and the failure this
- # rendering can carry — an empty finding set rendered as a request to
- # re-read — is the same class of bug as objectui#3152.
- - name: Render the claim re-read comment
- if: ${{ github.event_name == 'pull_request' }}
- env:
- RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- run: node scripts/render-changeset-claims-comment.mjs --from claims.json > claims-comment.md
-
- # One comment per pull request, updated in place on re-runs rather than
- # stacked. The marker it finds itself by is the FIRST LINE of the body it
- # is about to post — plain text, never an HTML comment, because this
- # repository has measured that GitHub deletes tag-shaped fragments from a
- # stored body and that a first-line HTML comment is eaten with them
- # (AGENTS.md). A marker that vanishes does not degrade gracefully: every
- # re-run would fail to find its predecessor and post again, which is the
- # stacking this is required to avoid.
- #
- # ⛔ The marker is not spelled here. It is read off the rendered body, so
- # there is only ever one copy of it.
- - name: Deliver the finding to the pull request
- if: ${{ github.event_name == 'pull_request' }}
- # A fork's pull request gets a read-only token, so the API call fails for
- # a reason that says nothing about changesets. `continue-on-error` plus
- # the try/catch below keep that from painting this job red — the same
- # pairing, for the same reason, as the budget report's comment step.
- continue-on-error: true
- uses: actions/github-script@v9
- with:
- # GitHub's API intermittently rejects valid run tokens with 401
- # (seen on PR #1627), so 401/403 must stay retryable here.
- retries: 3
- retry-exempt-status-codes: 400,404,422
- script: |
- const fs = require('fs');
-
- if (!fs.existsSync('claims-comment.md')) {
- core.warning('claims-comment.md was not rendered; skipping the pull request comment.');
- return;
- }
- const body = fs.readFileSync('claims-comment.md', 'utf8');
- const marker = body.split('\n', 1)[0].trim();
- if (!/^[a-z][a-z0-9-]{4,}$/.test(marker)) {
- core.warning(
- `Rendered body does not open with a marker line (read: ${JSON.stringify(marker)}). ` +
- 'Posting without in-place update — this comment may stack.',
- );
- }
-
- // ALL THREE readings (objectui#9509, objectui#9841). `findings` is the
- // went-false half — somebody else's pending changeset — `bornFalse` is
- // this pull request's own prose, and `selfContradiction` is one
- // changeset read against its own front matter. A run whose ONLY
- // finding is of one kind must still create the comment, or that kind
- // is delivered to the job log the ruling measured at zero answers out
- // of four. Every reading the gate gains needs a term here; landing one
- // without it is objectui#9842's shape — a patrol that runs and reaches
- // nobody — chosen knowingly.
- let findings = 0;
- try {
- const measured = JSON.parse(fs.readFileSync('claims.json', 'utf8'));
- findings =
- measured.findings.length +
- (measured.bornFalse ?? []).length +
- (measured.selfContradiction ?? []).length;
- } catch (error) {
- core.warning(`Could not read the finding count: ${error.message}`);
- }
-
- const target = {
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: context.issue.number,
- };
-
- try {
- const all = await github.paginate(github.rest.issues.listComments, {
- ...target,
- per_page: 100,
- });
- const mine = all
- .filter((comment) => (comment.body ?? '').split('\n', 1)[0].trim() === marker)
- .sort((a, b) => a.id - b.id);
-
- if (mine.length === 0) {
- // ⛔ Never CREATE a comment for an empty finding set. Most pull
- // requests here have nothing to re-read, and a reassuring "all
- // clear" on every one of them is how a channel gets muted — the
- // channel this ruling exists to un-mute.
- if (findings === 0) {
- core.info('Nothing to re-read, and no earlier request on this pull request.');
- return;
- }
- const created = await github.rest.issues.createComment({ ...target, body });
- core.info(`Posted the request to re-read: ${created.data.html_url}`);
- return;
- }
-
- await github.rest.issues.updateComment({ ...target, comment_id: mine[0].id, body });
- core.info(`Updated the request to re-read in place: ${mine[0].html_url}`);
- if (mine.length > 1) {
- core.warning(
- `${mine.length} comments carry this marker; updated the earliest and left the ` +
- 'rest alone. ⛔ Not this job\'s to delete — somebody else may own them.',
- );
- }
- } catch (error) {
- core.warning(`Could not deliver the finding to the pull request: ${error.message}`);
- await core.summary.addRaw(body).write();
- }
diff --git a/ui/.github/workflows/check-links.yml b/ui/.github/workflows/check-links.yml
deleted file mode 100644
index c316fc9c159..00000000000
--- a/ui/.github/workflows/check-links.yml
+++ /dev/null
@@ -1,175 +0,0 @@
-name: Check Links
-
-# Lychee is the EXTERNAL link checker. It makes real network requests and is
-# flaky by nature. Site-internal routes (`/docs/...`) are a different problem
-# with a different tool — `scripts/check-doc-links.mjs`, run by
-# `repo-gates.yml` (objectui#12214; `docs-links.yml` before it), which reads the
-# checkout and nothing else and is therefore
-# the one that gates pull requests. The two are complementary, not redundant;
-# do not move either one's job onto the other.
-#
-# SCAN SCOPE — stated as populations, never counted (objectui#7448,
-# objectui#7825). What gets swept is decided by the `args` glob list below and
-# by nothing else: the published documentation tree `content/docs/**` (the
-# fumadocs content source `apps/site/source.config.ts` declares —
-# `dir: '../../content/docs'`, baseUrl `/docs`), the repo-root INTERNAL tree
-# `docs/**` (ADRs, audits), and `README.md`.
-#
-# ⛔ Never write the size of either tree back into this comment. Two hand-copied
-# sizes stood here, one per tree, and both had drifted by the time anyone
-# looked, with nothing red over the whole distance — nothing fails on a stale
-# number written in a comment, which is exactly why it rots. Point at the
-# reading instead: `find content/docs docs -type f \( -name '*.md' -o -name
-# '*.mdx' \) | wc -l` in any checkout, and Lychee's own run summary, which
-# reports what it actually scanned. `check-links-workflow.test.ts` enforces
-# this — it reds on the next numeral that qualifies a document population
-# anywhere in this header.
-#
-# HISTORY, past tense on purpose — none of the following describes the scope
-# today. Until PR #3449 the `args` list named only the repo-root `docs/**`, so
-# not one published page had ever been scanned: this workflow was green because
-# of what it was not looking at. #3449 pointed it at both trees, and
-# `scripts/__tests__/check-links-workflow.test.ts` derives the expected scope
-# from the site config rather than restating it, so moving the content tree
-# turns that test red instead of blinding the sweep a second time.
-
-on:
- workflow_dispatch:
-
- # Weekly sweep, Sundays at 04:17 UTC.
- #
- # The PR that fixed #3213 deliberately did NOT add a cron, and it was right to
- # hold off: with the scope pointed at the wrong tree, a schedule only turns
- # one false-green report into a periodically produced false-green report. Now
- # that the scope is correct, #3213's ruling-B intent — a periodic sweep — is
- # real, so the schedule lands in the same change that makes it meaningful.
- #
- # Timing: off the top of the hour, because GitHub's scheduled-workflow queue
- # is most congested (and most delayed) at :00; Sunday because that is when the
- # repo contends least for runners.
- schedule:
- - cron: '17 4 * * 0'
-
- # ⛔ Do NOT enable the two triggers below — #3213's ruling B still stands.
- # An external link check goes over the network: one 502, rate-limit or
- # anti-scraping response from a third-party site turns an unrelated pull
- # request red, and its author can do nothing about it. `schedule` +
- # `workflow_dispatch` do not have that problem: this workflow blocks nobody,
- # so flakiness costs a second look and nothing else.
- # push:
- # branches:
- # - main
- # pull_request:
-
-# Least privilege: the job checks out and reads public pages. The token lychee
-# receives is for GitHub's API rate limit on link checks, a read.
-permissions:
- contents: read
-
-jobs:
- check-links:
- runs-on: ubuntu-latest
-
- # ── The job ceiling, DERIVED FOR THIS JOB (objectui#7956) ────────────
- # Before this line the only backstop was GitHub's 360-minute default, on the
- # one job in this repository that deliberately goes out over the network.
- #
- # ⚠️ The stakes here are NOT objectui#7270's. That card's argument leaned on
- # two of its three jobs producing a REQUIRED context on a shared serial
- # queue. Re-measured on `GET /repos/objectstack-ai/objectui/rules/branches/
- # main` at 2026-09-06T17:20Z, the required checks are `Lint`, `Type Check`,
- # `Build & E2E`, `Test (shard 1/4)` through `(4/4)`, `Build Docs` and
- # `Changeset Declaration` — this job produces none of them, and the header
- # above says it blocks nobody on purpose. What is left is the generic
- # exposure: a sweep wedged on an unresponsive host holds a runner for six
- # hours and then reports `cancelled`.
- #
- # ⛔ NOT inherited from `ci.yml`'s 10/15/20/30/40, nor from the ceilings
- # objectui#7270 derived — objectui#7048 fences exactly that. The arithmetic
- # below is this job's own.
- #
- # ⚠️ THE SAMPLE HAS TO BE CHOSEN, and the obvious choice is the wrong one.
- # `status=success` returns a `total_count` of 217 for this workflow, and
- # every single one of those runs is from 2026-01-24 .. 2026-01-28 on `push`
- # and `pull_request` — triggers this workflow no longer declares, under the
- # narrower scan scope it had before #3449. Those runs measure a different
- # job wearing this job's name, and deriving from them would put the ceiling
- # under today's honest slowest run, which is the hazard objectui#7048
- # fenced. They are recorded here and NOT used.
- #
- # - Population: every run under today's configuration —
- # `?event=schedule`, whose `total_count` is 5, window 2026-08-09T04:43Z
- # .. 2026-09-06T04:28Z. That is the entire population since the cron
- # landed, not a sample cut short; paging further buys the January runs
- # described above. Per-job wall clock from the Actions jobs endpoint
- # (`completed_at` minus `started_at`), never the run's total.
- # - min 15s / median 24s / p95 30s / max 31s.
- # - max/p95 = 1.03 — no fat tail inside the sample.
- # - ⚠️ All five ended `failure`, and that does NOT make them unusable: the
- # step conclusions show Lychee running 9s .. 24s and the job reaching its
- # post steps every time, so the failure is the sweep's own verdict
- # (`fail: true`, broken links found), not an abort partway through the
- # work. It is also the CONSERVATIVE side of the measurement — a link
- # that cannot be resolved is retried (`max_retries` in `lychee.toml`),
- # so a sweep that finds breakage does strictly more work than a clean
- # one. The max above therefore over-states a healthy run rather than
- # under-stating it.
- # - ⚠️ That every scheduled sweep so far has been red is a defect in its
- # own right and is filed as objectui#8126's sibling, objectui#8128. It
- # is deliberately NOT fixed by the same change that sets this ceiling:
- # the ceiling is about what happens when a sweep wedges, and the red is
- # about what the sweep found.
- #
- # Ceiling = the smallest round number that is both >= 3x max (1.6min) and
- # >= max + 15min (15.5min) => 20. That is ~39x the slowest sweep observed
- # under this configuration; a wedge dies in 20 minutes instead of 6 hours.
- #
- # ⚠️ The residual this does NOT bound, named rather than hidden:
- # `lychee.toml` declares a per-request `timeout` and `max_retries` at a
- # `max_concurrency` of 10, so a whole-run worst case scales with how much
- # this workflow sweeps — and that size is exactly the number the header
- # above forbids writing down, because it rots (objectui#7448,
- # objectui#7825). The additive 15 minutes is where that residual is
- # absorbed, not a proof against it. If a network-weather run ever does hit
- # this ceiling, the cost is a `cancelled` weekly sweep that blocks nobody —
- # which the header already accepts as this workflow's failure currency.
- #
- # ⚠️ Four of the six jobs objectui#7956 bounded land on 20 by this same
- # arithmetic. That is NOT one number copied across them, which precondition
- # ② of that card's triage forbids: for any job whose slowest run is under
- # 7.5 minutes the additive term dominates the multiplicative one, so the
- # rule puts every sub-minute job on the same rung. Each of the four carries
- # its own sample and its own derivation beside its own key.
- #
- # ⛔ Raising this toward 360 is the ruled-out non-fix (objectui#6577,
- # objectui#7048): the hang just runs longer and the gate still reports
- # `cancelled`.
- timeout-minutes: 20
- steps:
- - name: Checkout repository
- uses: actions/checkout@v7
-
- - name: Check links with Lychee
- uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0
- with:
- # Both trees:
- # content/docs/** — the published site's documentation (the fumadocs
- # content source). Never scanned before #3449; the whole point.
- # docs/**, README.md — the repo-root internal material (ADRs,
- # audits). Their external links are worth sweeping too and cost
- # nothing extra, so they stay.
- # Site-absolute routes (`/docs/...`) are NOT judged here — see the
- # `root_dir` section of lychee.toml.
- args: |
- --verbose
- --no-progress
- --config lychee.toml
- 'content/docs/**/*.md'
- 'content/docs/**/*.mdx'
- 'docs/**/*.md'
- 'docs/**/*.mdx'
- 'README.md'
- # Fail the action on broken links
- fail: true
- env:
- GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
diff --git a/ui/.github/workflows/ci.yml b/ui/.github/workflows/ci.yml
deleted file mode 100644
index b8be1e75182..00000000000
--- a/ui/.github/workflows/ci.yml
+++ /dev/null
@@ -1,2868 +0,0 @@
-name: CI
-
-on:
- push:
- branches: [main, develop]
- paths-ignore:
- - '**/*.md'
- - 'content/**'
- - 'docs/**'
- - 'apps/site/**'
- - '.changeset/**'
- # No `paths-ignore` here any more (objectui#3523, step 2). It skipped the
- # whole workflow on a docs-only / changeset-only PR, so every context this
- # file produces was simply absent there and none of them could be made
- # required. The path decision now lives inside the jobs — see the
- # `Decide whether this change needs a full run` step in `type-check`. `push`
- # above deliberately keeps its copy: nothing judges a push to `main`.
- pull_request:
- branches: [main, develop]
- # ── Merge queue (objectui#3523) ────────────────────────────────────────
- # The merge queue is ENFORCED on this repository by a ruleset — a direct push
- # to `main` returns 405 `Changes must be made through the merge queue`
- # (measured in #3243). Until this trigger landed, not one of the repository's
- # workflows subscribed `merge_group`: repo-wide `event=merge_group` runs stood
- # at total_count = 0, historically. A queue with nothing subscribed to it can
- # only have an EMPTY required-check set, so it rebuilt each PR on the current
- # `main` and let it through without validating anything.
- #
- # That is not a theoretical hole; it was cashed in on 2026-08-07. #3498 landed
- # a `scripts/` type gate, itself fully green, that left a TS2578 on `main`;
- # #3503, #3510 and #3516 then merged between 02:11Z and 02:15Z with `Type
- # Check` at conclusion=failure, and #3505 hot-fixed the result. objectstack
- # went through the same frames (objectstack#6067 -> #5615).
- #
- # `types:` is spelled out although `checks_requested` is the ONLY activity
- # type GitHub defines for `merge_group` today — the two spellings are
- # equivalent right now (objectstack's `ci.yml` and `lint.yml` use the bare
- # `merge_group:` form and produce queue builds normally, 3552 of them). Naming
- # the type means a second activity type added later cannot silently start
- # queue builds this workflow was never written for.
- #
- # `concurrency` below needs no merge-queue special case, and that was checked
- # rather than assumed: on `merge_group` the `github.event.pull_request` half of
- # the group expression is null, so the group falls back to `github.ref`, which
- # on a queue build is the queue's own generation — measured on objectstack,
- # `gh-readonly-queue/main/pr-6594-251e888ac9ace8226f3a8450951e5b40a0a84c2c`.
- # It can collide with neither a pull-request group (a bare PR number) nor a
- # push group (a commit sha since objectui#5422), so a queue build and the PR
- # build it came from never cancel each other.
- merge_group:
- types: [checks_requested]
- # ── Two speeds (objectui#12214, objectstack#22998) ─────────────────────
- # A `pull_request` or `merge_group` run carries the FAST SET: the `test`
- # matrix below runs the test files the change affects, selected by
- # `scripts/select-affected-tests.mjs`. `push` to `main` and this hourly
- # `schedule` carry the FULL BATTERY: every test file in sixteen shards, plus
- # the eight-shard coverage lane. Nothing is deleted or weakened; the whole
- # suite moved to `main`, where it runs on every merge and at least hourly.
- # GitHub runs `schedule` on the default branch only. The minute is off the
- # hour on purpose: scheduled workflows queue behind everyone else's `:00`.
- # On `schedule` the concurrency group below falls through to `github.ref`
- # (`refs/heads/main`), which no push (a sha) or pull request (a number)
- # shares; an hourly run is cancelled only by the next one, an hour later.
- schedule:
- - cron: '26 * * * *'
-
-# ── Concurrency (objectui#5422) ───────────────────────────────────
-# On `pull_request` the group is the PR number and `cancel-in-progress` cancels
-# that PR's previous run. That is correct and is deliberately UNCHANGED here: a
-# superseded PR push has nothing left to deliver.
-#
-# On `push` the group now carries `github.sha`, so every merge to `main` gets a
-# group of its own and no merge can cancel another merge's run. Until #5422 the
-# push branch of this expression fell back to `github.ref` — `refs/heads/main`
-# for EVERY merge — so each merge cancelled the previous merge's still-running
-# CI.
-#
-# That matters because the push lane is the only lane that runs the coverage
-# gate (`test-coverage` + `coverage-report` below are push-only), and the merged
-# report of every coverage shard is what enforces `coverage.thresholds` for a
-# commit. Measured on this repository over the 64 completed push-lane runs on
-# `main` between 2026-08-23T06:34Z and 2026-08-24T13:56Z: the merged report was
-# produced for 20, five lost it to a red suite, and 39 — 61% — lost it to
-# CANCELLATION. Over the 30 strictly consecutive push runs of 2026-08-24 alone
-# it is 15 of 28 completed (54%). The lane needs ~13.5 min end to end while the
-# median inter-merge interval is 8.8 min, so most merges are overtaken before
-# the gate can report at all.
-#
-# ⚠️ This has to live at WORKFLOW level, not on the coverage jobs. A job-level
-# `concurrency:` only decides whether a job waits for another job in the same
-# group; it grants no exemption from the workflow-level `cancel-in-progress`,
-# which cancels the whole run and every job in it. A per-sha group on
-# `test-coverage` alone would therefore NOT have protected it.
-#
-# ⛔ Not `cancel-in-progress: false` on a `github.ref` group: that does not
-# serialise. With `cancel-in-progress` unset GitHub holds ONE pending run per
-# group and DISCARDS the rest — measured on this repository's release lane at
-# 95 of 200 runs never executing at all (objectui#5395). It would trade
-# cancellation for silent dropping, which is strictly worse.
-#
-# The cost is the one option A named on #5422: a burst of merges no longer
-# collapses into a single surviving run, so the push lane's other jobs also run
-# to completion and runner minutes rise. Nothing is weakened by that — every
-# merged commit now gets its own verdict, which is the only post-merge signal
-# this repository has while the merge queue validates nothing (objectui#4986).
-#
-# Trigger by trigger:
-# pull_request `github.event.pull_request.number` is set, so the chain stops
-# there => ci-CI-
-# push the number is null and `github.event_name == 'push'` is true,
-# so `&&` yields the sha => ci-CI-
-# merge_group the number is null and the `push` test is false, so `&&`
-# yields false and the chain falls through to `github.ref`,
-# which on a queue build is the queue's own generation ref
-# (`gh-readonly-queue/main/pr--`) — colliding with
-# neither a PR group nor a push group, exactly as before.
-# Least privilege for the job token. Every job without its own `permissions:`
-# block (changeset-check, type-check, test, test-dist-pins, test-coverage,
-# e2e, docs) only checks out, builds, tests, and saves caches and artifacts;
-# none of those calls the API with this token (actions/cache and
-# actions/upload-artifact use the runner's own token), so `contents: read` is
-# all they get. The two jobs that read the Actions API (test-aggregate,
-# coverage-report) declare their own block, which replaces this one.
-permissions:
- contents: read
-
-concurrency:
- group: >-
- ci-${{ github.workflow }}-${{ github.event.pull_request.number
- || (github.event_name == 'push' && github.sha)
- || github.ref }}
- cancel-in-progress: true
-
-jobs:
- changeset-check:
- name: Changeset Fixed Group Check
- runs-on: ubuntu-latest
- timeout-minutes: 10
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v7
-
- - name: Verify all packages are in changeset fixed group
- run: node scripts/check-changeset-fixed.mjs
-
- # Types were entirely unguarded until #2911: no job ran `type-check`, and
- # `turbo build` only checks types for packages whose build script happens to
- # invoke `tsc` — the 22 `vite build` packages transpile without checking. A
- # `maplibre-gl@6` breakage therefore sat on `main` for a day behind a green CI.
- #
- # Note that `pnpm type-check` alone is not a sufficient gate: turbo silently
- # skips any package with no `type-check` script, so a package without one
- # reads as passing. The coverage guard is what makes that impossible, and it
- # runs first because it is instant and catches the structural mistake.
- type-check:
- name: Type Check
- runs-on: ubuntu-latest
- timeout-minutes: 20
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v7
- with:
- submodules: true
- # `pnpm check:i18n-drift` (below) compares the locale packs on this
- # branch against the packs at its MERGE BASE with the target branch,
- # so it needs history — and checkout's default is a depth-1 clone,
- # where `git merge-base` has nothing to find. The gate treats an
- # unresolvable base as a hard failure rather than a skip, so getting
- # this wrong is a red build rather than a silent pass; it is spelled
- # out here so it stays that way. Pinned by
- # `scripts/__tests__/check-i18n-en-drift.test.ts`.
- fetch-depth: 0
-
- # ── Always report; run only when it matters (objectui#3523) ──────────
- # This context used to be invisible on a docs-only or changeset-only pull
- # request, because `on.pull_request.paths-ignore` skips the WHOLE workflow
- # when every changed file matches — GitHub has no per-job path filter. No
- # workflow means no check run, and a REQUIRED check that never reports
- # does not fail the PR, it leaves it pending forever; in the merge queue
- # it fails on the ruleset's 60-minute status-check timeout instead. So
- # these contexts could not be required while the filter lived on the
- # trigger, which is the second half of the #3523 P0 (#3509 measured a
- # docs-only PR starting zero of them).
- #
- # The filter therefore moved from the trigger into the job: the job always
- # runs and always reports, and the paths decide only whether the expensive
- # steps execute. This is the shape the `docs` job below has used since
- # #3450 — `should_run` plus a per-step `if:` — not a new mechanism.
- #
- # The exclusion list below IS the `paths-ignore` it replaced, unchanged,
- # so which pull requests pay for a full run is exactly as before. The
- # `push` trigger keeps its `paths-ignore`: branch protection and the merge
- # queue judge pull requests and queue builds, never pushes to `main`, so
- # filtering the push lane at the trigger costs nothing there and saves a
- # full run on every docs merge.
- #
- # A failure inside this step means RUN, never SKIP. objectstack#4928 named
- # that the filter contract, after a filter job that skipped when it could
- # not tell produced a fully green, zero-gate pull request. Every gate in
- # this workflow is spelled that way — including the `docs` job below, whose
- # capture swallowed its own failure into an empty result until
- # objectui#3723. `scripts/__tests__/merge-queue-reporting.test.ts` holds
- # every `CHANGED=$(git diff …)` in this file and in `lint.yml` to the
- # fail-open form, so a new gate cannot be added in the closed spelling.
- #
- # ⛔ This job has NO markdown second stage, and objectui#9241 measured
- # what that costs rather than asserting it away: traced under the `fs`
- # harness objectui#9096 built, this job's population DOES open markdown --
- # `pnpm check:spec-symbols` reads every page under `content/docs/**`,
- # which the pathspec below excludes. ⇒ a documentation-only pull request
- # skips the gate that reads that documentation. The reading, and the fence
- # around acting on it, are on the `test` job's decide step below; the
- # widening is objectui#9241's to grade, ⛔ not a thing to add here on
- # sight.
- - name: Decide whether this change needs a full run
- id: relevant
- run: |
- if [ "${{ github.event_name }}" != 'pull_request' ]; then
- echo 'should_run=true' >> "$GITHUB_OUTPUT"
- echo 'Not a pull request: push is filtered at the trigger, and a merge_group build is the last validation before main. Running everything.'
- exit 0
- fi
- if ! CHANGED=$(git diff --name-only \
- '${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}' -- \
- . \
- ':(exclude,glob)**/*.md' \
- ':(exclude,glob)content/**' \
- ':(exclude,glob)docs/**' \
- ':(exclude,glob)apps/site/**' \
- ':(exclude,glob).changeset/**'); then
- echo 'should_run=true' >> "$GITHUB_OUTPUT"
- echo 'Could not diff against the merge base. Running everything rather than skipping silently.'
- exit 0
- fi
- if [ -n "$CHANGED" ]; then
- echo 'should_run=true' >> "$GITHUB_OUTPUT"
- echo "$CHANGED"
- else
- echo 'should_run=false' >> "$GITHUB_OUTPUT"
- echo 'Only ignored paths changed. Skipping the steps below; this check still reports.'
- fi
-
- - name: Enable Corepack and download the pinned pnpm
- if: steps.relevant.outputs.should_run == 'true'
- run: bash scripts/ci-setup-pnpm.sh
-
- - name: Verify pnpm version
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm --version
-
- - name: Setup Node.js
- if: steps.relevant.outputs.should_run == 'true'
- uses: actions/setup-node@v7
- with:
- node-version: '22.x'
- cache: 'pnpm'
-
- # Every package must be either type-checked or explicitly declared as a
- # known gap. Runs before install: it only reads package.json files.
- - name: Verify type-check coverage
- if: steps.relevant.outputs.should_run == 'true'
- run: node scripts/check-type-check-coverage.mjs
-
- # objectui#12957: every @objectstack/* package resolves to the build of the
- # commit `.objectstack-sha` pins, read from node_modules/.cache/objectstack, so
- # it must be there before pnpm resolves anything. Restored by the pin and built
- # only on a miss (scripts/objectstack-pin.mjs); the save is this job's last step.
- - name: Restore the pinned @objectstack build
- id: objectstack-pin
- if: steps.relevant.outputs.should_run == 'true'
- uses: actions/cache/restore@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
-
- - name: Build @objectstack at the pin (skips a restored build)
- id: objectstack-build
- if: steps.relevant.outputs.should_run == 'true'
- run: node scripts/objectstack-pin.mjs build
-
- - name: Install dependencies
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm install --frozen-lockfile
-
- # A bare specifier RESOLVING from a package directory does not mean that
- # package declares it: the workspace root's `devDependencies` sit on the
- # upward resolution path from everywhere, and on no consumer's. So
- # `require.resolve('react', { paths: ['packages/core/src'] })` succeeds
- # although `@object-ui/core` declares react in no field at all
- # (objectui#4394), and a react import added there would typecheck, build
- # and pass every local suite before breaking the published package for the
- # first consumer without React installed. This gate asks the question root
- # hoisting cannot answer for anyone — is it DECLARED — and its first full
- # run found a live instance: `@object-ui/plugin-detail` shipping a bare
- # `react-router-dom` import its manifest never mentioned. Same placement
- # rationale as the steps below: it parses the sources with `typescript` and
- # reads every package.json, so it needs the install but nothing built.
- - name: Verify every imported package is declared by the package that ships it
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm check:phantom-deps
-
- # A file inside a package that names its OWN package resolves through that
- # package's `exports` map to `dist/`, and `type-check` waits on `^build`
- # (the DEPENDENCIES' builds), never on the package's own — so on a cold
- # cache the declarations do not exist and the file fails with TS2307. It is
- # green on every machine that has ever run a build, which makes CI the only
- # place it can be seen: PR #4789's first run was red on exactly this, one
- # line in `packages/fields/src` (objectui#4801). Parses sources with
- # `typescript` through the sibling gate's scanner, so it needs the install
- # and nothing built — same placement rationale as the step above.
- - name: Verify no package imports itself by name
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm check:self-import
-
- # A source file nothing imports and nothing exports sits in a PUBLISHED
- # package indefinitely: `check-dist-completeness` asks whether `dist/` holds
- # what `tsc` emits, `check-readme-exports` compares documented exports
- # against shipped ones, and neither can see a file that is in the tarball
- # while being reachable from nothing. Both instances found this week —
- # objectui#7319 and objectui#7397 — were found by a human reading unrelated
- # code, which is the detection mechanism this replaces. The hazard is not
- # the bytes: the file objectui#7319 removed carried the same export name as
- # a live engine one package over and evaluated no predicate, so
- # name-completion alone could have wired a silently wrong renderer into a
- # published package (objectui#7515).
- #
- # Reachability here has TWO roots. `packages/components` reaches two live
- # files — the `use-sync-external-store` shims — only through
- # `vite.config.ts` `resolve.alias`, whose importer is a bundled dependency
- # no source file names; a walk that skips that leg reports exactly those two
- # as dead on its first run, and a gate that cries wolf on live files gets
- # switched off rather than fixed. Scope is DECLARED per package and the
- # uncovered remainder is printed as a derived count on every run, because
- # the alias mechanisms differ per package and a gate that covers one
- # correctly beats one that covers forty with false positives.
- #
- # Parses sources with `typescript` through the sibling gate's scanner and
- # reads build configs as text, so it needs the install and nothing built —
- # same placement rationale as the two steps above.
- - name: Verify no covered package ships a source file nothing reaches
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm check:unreferenced-sources
-
- # A JSDoc `@example` on an exported hook is not decoration: call sites copy it.
- # When the ruling the example encoded moves, the prose stays, and every later
- # copy is seeded from the prose rather than from the code — so fixing the call
- # sites without fixing the doc comment re-seeds them. That class cost two cards
- # and three copied call sites (objectui#7627, objectui#7638) with nothing in CI
- # able to see it; objectui#7617's `check-spec-symbol-derivation` was cited as
- # covering it twice and does not — its rule 4 judges `@objectstack/spec`
- # citations at member granularity and has nothing to say about prose that
- # prescribes a LOCAL spelling.
- #
- # This step fails only when a documented symbol's own `@example` hand-spells
- # what its real call sites obtain by CALLING a shared reader. Parses sources
- # with `typescript` through the same scanner as the two steps above, so it
- # needs the install and nothing built.
- - name: Verify no doc comment prescribes a spelling a shared reader owns
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm check:doc-example-readers
-
- # `BaseSchema` is `.passthrough()`, so an `on*` key that is not declared by
- # its arm is NOT refused — it stops being judged and the value is KEPT, then
- # reaches the renderer that reads it. objectui#7664 measured that on the built
- # dist: `{ type: 'kanban', columns: [], onCardClick: { action: 'toast' } }`
- # went from REFUSED to ACCEPTED with the object surviving into the parsed
- # output, while every gate here stayed green. The objectui#6124 ledger could
- # not see it — its population is two hand-written arrays, and that change
- # re-keyed the arm by SUBSTITUTION, holding the length constant — so a count
- # ratchet would have been green too (objectui#7753 rejected that option on
- # exactly this measurement).
- #
- # This step derives BOTH populations instead: the arms from every
- # `type: z.literal(…)` in `packages/types/src/zod`, and the read sites from
- # every real `ComponentRegistry.register(…)` call, following the document
- # into the components each registration hands it to. It has to live in
- # `scripts/` because the read sites are spread across `@object-ui/plugin-*`
- # and `packages/components`, which `@object-ui/types` may not import —
- # `check:phantom-deps` rejects it and it would close a cycle. Parses sources
- # with `typescript` through the same scanner as the steps above, so it needs
- # the install and nothing built.
- - name: Verify every handler key a registered renderer reads is declared by its arm
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm check:handler-key-reads
-
- # objectui#7714 ruled one client behaviour and its PR implemented that ruling
- # by ENUMERATING the writers it knew of. Two. objectui#8057 hit the same
- # defect on a third in that card's own required dogfood, and objectui#8676's
- # sweep found nine more — and, the half that outlives the count, the sweep
- # shape the question is naturally asked in (`client.save(`) CANNOT SEE them:
- # it returns zero over the very file objectui#8057 is about, because the call
- # is `client.save(type, ...)`. A hand list of writers is stale the next
- # time somebody adds one, and nothing says so.
- #
- # So this step enumerates DOORS, not writers. The writer set is open and
- # nobody has to announce a new member; the transport set is closed and this
- # repo owns it. It derives every call that PUTs `/meta/:type/:name` —
- # resolving the URL through templates, fields and helper returns, because
- # the central door says nothing at its own call site — and requires each one
- # that can carry an object document to reach the guard. Parses sources with
- # `typescript`, so it needs the install and nothing built.
- - name: Verify every object-metadata write door applies the write guard
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm check:metadata-write-doors
-
- # A build tsconfig that excludes tooling by FILE NAME (`*.test.ts`) stops
- # the files that happen to be named that way and nothing else. The first
- # shared helper added to a `__tests__/` directory is then a program input,
- # and an emitting program writes it into the published `dist`. That has
- # happened three times — objectui#4006 (73 `*.test.d.ts` across two
- # packages), objectui#4836 (9 more, one of them an emitted module whose
- # first statement imports `vitest`) and objectui#6943 (the same package as
- # the first, because that fix wrote the name form) — and every one was
- # found by a human, never by a gate. objectui#7212 measured the standing
- # exposure: 29 published packages carried the name form with ZERO offending
- # files, green because nobody had added such a helper yet. This gate reads
- # `exclude` ARRAYS only: no build, no artifact, no emit model, which is what
- # keeps it clear of the modelling objectui#4846 declined for the
- # artifact-level gate. That gate stays as the second line of defence — it
- # is the only criterion that cannot be wrong about what actually ships, and
- # it lives in `published-dist-gate.yml` because it needs a full-repo build.
- # Its runnable alias is deliberately NOT named anywhere in this file:
- # `check-published-dist-tooling.test.ts` asserts by substring that no
- # per-PR workflow mentions it, which is how the ruling that kept a
- # full-repo build off the PR path is held — prose naming it would read the
- # same to that scan as a step running it. Config
- # reads plus the sibling gate's `typescript`-importing scanner, so it needs
- # the install and nothing built, same as the two steps above.
- - name: Verify published build tsconfigs exclude tooling DIRECTORIES, not just names
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm check:published-tsconfig-exclude
-
- # `sideEffects` is a PUBLISHED CONTRACT, and an ARRAY form of it fails in
- # the one direction nothing can witness: an INCOMPLETE array drops a
- # registration inside a CONSUMER's bundle, with no error, no warning and
- # exit 0 — the same failure mode as `"sideEffects": false`, only quieter.
- # `@object-ui/app-shell` declares one because both simpler answers are
- # measurably wrong for it (objectui#6683; `false` drops three live SDUI
- # widget registrations, measured in objectui#6535). This gate re-derives
- # the enumeration from the module bodies and fails when the array and the
- # derivation disagree in EITHER direction — a missing registrar, or a
- # stale name whose module no longer registers anything.
- #
- # Placed here rather than with the build steps for the same reason as the
- # two above: it parses sources with `typescript` and reads package
- # manifests, so it needs the install and nothing built. The artifact half
- # of the same contract — do the registrations survive a real bundler —
- # cannot run here at all and lives in `performance-budget.yml`, which
- # builds the console.
- - name: Verify every `sideEffects` array names exactly its registering modules
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm check:side-effects-array
-
- # A block that WRAPS `ElementDataSourceGate` reads the spec's
- # `PageComponentSchema.dataSource`; the input declaration for that key is
- # emitted mechanically at the wrapping seam (`elementDataSourceBlock` ->
- # `Registry.register`), per the maintainer ruling of 2026-08-29, so no
- # block hand-writes it. This gate is the other half of "and cannot forget
- # it": a file that starts rendering the gate without reaching the seam
- # would publish an authoring surface missing the one key its own runtime
- # honours, and the html tier would go back to reporting the only spelling
- # that works as `unknown-prop` (objectui#6678). Source-only — it needs the
- # install and nothing built.
- - name: Verify every ElementDataSourceGate wrapper reaches the declaration seam
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm check:element-data-source-declaration
-
- # Node's ESM resolver does not extension-search relative specifiers, so an
- # extensionless `./SchemaRenderer` in a published `dist/` is unloadable
- # under plain Node — `@object-ui/react`'s entry died with
- # ERR_MODULE_NOT_FOUND (objectui#4538). `tsc` never rewrites specifiers, so
- # for a package whose build preserves them the SOURCE specifier is the
- # emitted one, and the defect can be judged without building anything.
- # That is why the cheap leg runs here: it reads sources, needs only the
- # install, and catches a regression the moment it is written.
- #
- # The expensive leg — which BUILDS and actually imports each published
- # entry — is `.github/workflows/node-esm-load-gate.yml`. It is not here on
- # purpose: it needs a full-repo build, which this repository deliberately
- # does not do per pull request (the same trade `published-dist-gate.yml`
- # records at length).
- - name: Verify published ESM packages emit resolvable relative specifiers
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm check:esm-specifiers
-
- # A local type/const declared under a `@objectstack/spec` export's NAME reads
- # to the next agent as the spec's own definition — four such symbols had
- # already drifted from the spec they claimed to be (objectstack#4115). Needs
- # the install (it reads the spec's own `.d.ts`) but not the build, so it runs
- # before the expensive steps.
- - name: Verify spec-named symbols are derived, not hand-written
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm check:spec-symbols
-
- # Each action renderer hands the runner an explicit key WHITELIST, so a new
- # spec action key is silently dropped until five separate lists are edited —
- # and nothing fails while they are not: the key parses, publishes and reads
- # as honoured. Six instances were found by hand, one at a time
- # (objectstack#6837 `bodyExtra`, #6938 `bodyShape`, objectui#3646
- # `resultDialog`, objectui#4192 `label`/`description`), which is why
- # objectui#4050's ruling asks for a gate rather than a seventh review. Same
- # placement rationale as the step above: it reads `@objectstack/spec`'s own
- # zod shapes and parses the renderers with `typescript`, so it needs the
- # install but nothing built.
- - name: Verify action renderers forward every key the runtime reads
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm check:action-forward-parity
-
- # A field designer offering a control that writes a key `FieldSchema`
- # refuses BY NAME is a save-blocking 422 (`INVALID_METADATA`) that blocks
- # EVERY later save of the object, and the author cannot tell from the UI
- # which key did it. It had been filed three times — objectui#4644
- # `indexed`, #4687 `distance_metric`, #4676 `placeholder` — each closed
- # with a per-key tombstone written AFTER the instance was found in
- # production, and nothing detected the next one (objectui#5761). This gate
- # compares the designers' statically declared payload shapes against the
- # installed `FieldSchema`'s own accept set. Reads sources with `typescript`
- # and the installed spec, so it needs the install and nothing built — same
- # placement rationale as the step above. It covers a documented SUBSET of
- # the write path (keys reaching the payload only via a `patchDef` spread
- # are outside its reach); the boundary is stated in the script's docblock,
- # and its draft-I/O half runs in the test suite as
- # `object-fields-io.spec-keys.test.ts`.
- - name: Verify designer field payloads declare only keys FieldSchema accepts
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm check:designer-field-key-parity
-
- # lucide retires a spelling by DROPPING IT FROM the runtime `icons` record
- # while keeping it as a deprecated named export. A retired name therefore
- # still imports, still type-checks and still renders wherever it is used as
- # a COMPONENT (`Edit === SquarePen`, `Filter === Funnel`) — and resolves to
- # nothing wherever it is used as a STRING, because the string lookups read
- # that record. Nothing goes red in either direction, which is why it had to
- # be repaired twice, in two packages, by two cards, each leaving a LOCAL pin
- # behind (objectui#5586, objectui#5622). This gate replaces those pins with
- # one predicate over the population, judged by the record itself and never
- # by a list of retired spellings — a list would age the moment lucide
- # retires the next name, and age silently. It also RE-DISCOVERS the
- # resolvers on every run: its first pass found four record-reading resolvers
- # objectui#5633's hand-built table did not know about. Reads sources with
- # `typescript` and the installed lucide, so it needs the install and nothing
- # built — same placement rationale as the step above.
- - name: Verify authored icon names are live lucide `icons` keys
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm check:icon-record-names
-
- # A key a component asks `t()` for must exist in the `en` pack.
- # `all-locales-key-parity.test.ts` compares packs to EACH OTHER, so ten
- # packs identically missing a key is full parity and full parity is green
- # — objectui#3517 lived there for months, and this gate's first full run
- # found 258 more keys in the same blind spot. Same placement rationale as
- # the step above: it imports `typescript` to parse the sources, so it
- # needs the install, but nothing built.
- - name: Verify t() call-site keys exist in the en locale pack
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm check:i18n-keys
-
- # The step above and `all-locales-key-parity.test.ts` both read KEYS. When
- # an `en` VALUE changes and the nine translations do not, every key-shaped
- # gate stays green — objectui#3582 and objectui#3625 were eight packs each
- # serving a retired sentence, the second one as idiomatic native-script
- # translations that no mechanical judgement can distinguish from healthy.
- # This gate judges the event instead of the state: `en` changed here, the
- # translations must change here too. Needs the install (it parses the
- # packs with `typescript`) and the `fetch-depth: 0` above (it diffs
- # against the merge base), but nothing built.
- - name: Verify changed en strings were followed by the nine translations
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm check:i18n-drift
-
- # The two steps above judge the TEN LOCALE PACKS. The metadata-admin
- # designer keeps its own module-local string tables (~3600 lines of
- # user-visible text in packages/app-shell/src/views/metadata-admin/i18n.ts)
- # and both of them are blind to it BY CONSTRUCTION — measured in
- # objectui#8388 and quoted in the header of the script this step runs. The
- # repo's report-only dead-keys sweep does read that table, but it asks about
- # DEAD keys, its corpus is the UNION of the two halves so a one-sided key is
- # not a subject to it, and it deliberately runs in NO workflow ("This is a
- # REPORT, not a gate" — a pin in its own test suite keeps it out of every
- # workflow file, which is why this comment does not spell its name). So
- # until objectui#8834 an author could add an English string and forget the
- # Chinese one with every gate in this repo green. This step is
- # the state half of that fix — en is a subset of zh, and the shared rows
- # carry the same {placeholders}; the event half (an en VALUE changed and zh
- # did not) is a second population inside check:i18n-drift above. Needs the
- # install (it parses the table with `typescript`) but nothing built, and no
- # git history: it judges one commit.
- - name: Verify the metadata-admin designer string tables stay paired
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm check:i18n-designer-parity
-
- # `scripts/` is not a workspace package, so `pnpm type-check` (i.e.
- # `turbo run type-check`, which walks package.json `scripts`) structurally
- # cannot reach it, and the coverage guard above decides coverage per
- # PACKAGE so it could not see the gap either. Until objectui#3494 that
- # left every file in `scripts/__tests__/` compiled by nothing — ten pin
- # tests holding this workflow, `docs-links.yml`, `lint.yml` and the
- # changeset guard in place, none of which the compiler had ever read. A
- # pin test that `tsc` never reads can assert a contract that no longer
- # type-checks and still print green (objectui#3181).
- #
- # Placed here, not further down: nothing in tsconfig.scripts.json's
- # program imports an @object-ui/* package, so it needs the install but not
- # the `^build` that `pnpm type-check` depends on — it is cheap and fails
- # fast. `scripts/__tests__/scripts-type-check.test.ts` pins that premise,
- # this step's presence, and its position after the install.
- - name: Type-check scripts/
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm type-check:scripts
-
- # The repo-root `vitest.config.mts` had no RUNNABLE gate of its own
- # (objectui#7328). A program did already contain it —
- # `apps/console/tsconfig.node.json` lists `../../vitest.config.mts` and
- # `vitest.config.ts` outright (objectui#3476) — but the only thing that
- # ran that program was the console's own `type-check` script, which is
- # `tsc --noEmit && tsc -b tsconfig.node.json --force` behind
- # `turbo run type-check`, whose task dependsOn `^build`. So the cheapest
- # compiler that reads the root Vitest config was reachable only through
- # the most expensive job in the repo, and no local gate union short of a
- # full workspace build could red on it.
- #
- # That is not hypothetical. PR #7291 pushed a conditionally spread `dist`
- # project whose literal `extends: true` widened to `boolean`, degrading
- # the whole `projects` array to `never[]`; every gate the author ran was
- # green and CI reported three errors, two of them at
- # `../../vitest.config.mts`. This step is the same compiler, invoked
- # directly.
- #
- # Placed in the CHEAP half beside `pnpm type-check:scripts`, and for the
- # same measured reason: nothing in this project's program imports an
- # @object-ui/* package (its `@object-ui/*` occurrences are alias-map
- # STRINGS in `vite.config.ts`), so it needs the install and nothing built
- # — 2.0s on a worktree with no `dist/` anywhere.
- # `scripts/__tests__/vitest-config-type-check.test.ts` pins that premise,
- # this step's presence and its position.
- - name: Type-check the root Vitest config
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm type-check:vitest-config
-
- # ── Cache bookkeeping cannot void a recorded verdict (objectui#6577) ──
- # THE ORDERING, because it is the only thing a future reader needs in
- # order to judge whether this step may be touched: this job's verdict is
- # recorded by the three checking steps below, the last of which is
- # `Type-check repo-root vitest setup files`. EVERYTHING AFTER THAT POINT
- # IS BOOKKEEPING — writing `.turbo/cache` back for the next run. It must
- # never be able to discard an answer the gate has already produced.
- #
- # It could, and once did. On 2026-08-26 the merge-group build of #6571
- # was ejected from the queue with `CI_FAILURE` while every substantive
- # check passed: all 21 real steps of this job succeeded, the gate steps
- # included, and then the runner-generated `Post Turbo Cache` step spent
- # 13m09s (789s) inside the cache upload and was still there when this
- # job's `timeout-minutes: 20` fired at 20m02s. The job went `cancelled`,
- # the merge queue cannot tell `cancelled` from `failure`, and a pull
- # request that had passed was dequeued — 20 minutes of head-of-line
- # blocking for every lane, then a full re-run. Same shape as the
- # objectui#5304 apt block in the `e2e` job below: an unbounded network
- # call whose only backstop was the job ceiling, which converts a
- # transient fault into a CANCELLED check — a gate that reports nothing.
- #
- # A transient, not a property of this workflow: the same job on the same
- # PR head thirteen minutes earlier (job 98190108000) went green in 5m53s
- # and its cache save took ONE SECOND. That 1s is what the bound on the
- # save step at the end of this job is sized against.
- #
- # Why the restore/save SPLIT and not a timeout on a single cache step:
- # combined `actions/cache` declares `main: dist/restore/index.js` plus
- # `post: dist/save/index.js`, so the save is a step the RUNNER generates
- # (`Post Turbo Cache`, step #40 in the incident) — not an authored step,
- # and no workflow syntax attaches `timeout-minutes` or
- # `continue-on-error` to it. Splitting moves the save into the main
- # phase, where both are ordinary documented step keys. The action's own
- # `save-always` deprecation text points at this same split.
- #
- # The restore half is deliberately left UNBOUNDED. A restore stall fails
- # before any verdict exists — a gate that did not run, which is honest —
- # rather than a recorded verdict discarded, and bounding it would force a
- # cold check under this same 20-minute ceiling.
- #
- # ⛔ Raising `timeout-minutes: 20` is not the fix and was ruled out on the
- # card: a larger ceiling only buys a longer hang, still ending in
- # `cancelled`, and lifting a gate's ceiling weakens the gate.
- #
- # ONE restore key, the job's own prefix (objectui#12222). `actions/cache`
- # tries every key on the run's OWN ref before it looks at `main`, and
- # the bare `turbo--` key this step used to end with matched any job's
- # cache on that ref: on that card's first pull-request run it restored
- # the `docs` job's cache, saved minutes earlier by the same run (about
- # 2 GB, most of it unused), and so never reached `main`'s type-check
- # cache at all. Pull-request and queue runs save nothing now (see the
- # prune step below), so on those refs this key finds nothing and the
- # lookup falls through to `main` -- whose entry the full battery wrote.
- - name: Restore Turbo Cache
- id: turbo-cache
- if: steps.relevant.outputs.should_run == 'true'
- uses: actions/cache/restore@v6
- with:
- path: .turbo/cache
- key: turbo-${{ runner.os }}-type-check-${{ github.sha }}
- restore-keys: |
- turbo-${{ runner.os }}-type-check-
-
- # ── Two speeds for the type gate (objectui#12222, objectstack#22998) ──
- # The same doctrine as the `test` job below. A `pull_request` or
- # `merge_group` run type-checks the FAST SET: the packages the change
- # sits in plus every package that depends on them -- turbo's own
- # `--filter=...[]`, against the PR's base sha or the queue's base
- # sha -- and replays from the restored `.turbo/cache` every one whose
- # hash did not move. `push` to `main` and the hourly `schedule` run the
- # FULL BATTERY: every package, with `TURBO_FORCE` so not one verdict is
- # replayed -- which is also what writes the cache those fast runs read.
- #
- # THE ROOT RULE, because the package graph cannot see a file that sits in
- # no package (measured on objectui#12222: a change to the root
- # tsconfig.json, the root package.json, scripts/vite-*.ts or e2e/
- # selects zero packages). A change to any of these means EVERY package
- # is affected, and the fast run becomes a whole-workspace run:
- # - pnpm-lock.yaml, pnpm-workspace.yaml, the root package.json,
- # turbo.json, .npmrc, a root tsconfig*.json, anything in patches/;
- # - every `$TURBO_ROOT$` input of turbo's `type-check` and `build`
- # tasks (the scripts and root files those tasks' inputs name);
- # - every `globalDependencies` entry;
- # - the inputs of every root task `type-check` dependsOn (the e2e
- # program), which runs only as a dependency of a selected package.
- # The last three are read out of turbo.json on every run by
- # `scripts/select-type-check-scope.mjs`, never copied: the rule follows
- # the file turbo hashes from. A selector that cannot decide -- no base
- # sha, a base git cannot resolve, a turbo dry run that fails -- means the
- # whole workspace, never a skip. Nothing is skipped or weakened: a
- # package the fast set leaves out is one the change cannot reach through
- # the package graph or a turbo input, and the full battery on `main`
- # re-derives every verdict within the hour.
- - name: Decide which packages to type-check
- id: tc-scope
- if: steps.relevant.outputs.should_run == 'true'
- env:
- EVENT: ${{ github.event_name }}
- PR_BASE: ${{ github.event.pull_request.base.sha }}
- QUEUE_BASE: ${{ github.event.merge_group.base_sha }}
- run: |
- case "$EVENT" in
- pull_request) base="$PR_BASE" ;;
- merge_group) base="$QUEUE_BASE" ;;
- *)
- echo 'scope=full' >> "$GITHUB_OUTPUT"
- echo "A $EVENT run is the full battery: every package type-checked, no verdict replayed."
- exit 0
- ;;
- esac
- status=0
- if [ -z "$base" ]; then
- echo "::warning::no base sha on this $EVENT event; type-checking the whole workspace rather than guessing."
- status=2
- else
- node scripts/select-type-check-scope.mjs --base "$base" > "$RUNNER_TEMP/tc-scope.out" || status=$?
- cat "$RUNNER_TEMP/tc-scope.out"
- fi
- verdict=''
- if [ "$status" -eq 0 ]; then
- verdict=$(tail -n 1 "$RUNNER_TEMP/tc-scope.out")
- fi
- case "$verdict" in
- scope=affected)
- echo 'scope=affected' >> "$GITHUB_OUTPUT"
- echo "base=$base" >> "$GITHUB_OUTPUT"
- ;;
- scope=workspace)
- echo 'scope=workspace' >> "$GITHUB_OUTPUT"
- ;;
- *)
- echo "::warning::the type-check scope selector reached no verdict (exit $status); type-checking the whole workspace rather than skipping silently."
- echo 'scope=workspace' >> "$GITHUB_OUTPUT"
- ;;
- esac
-
- # `type-check` dependsOn `^build`, so this builds workspace dependencies
- # first: the checked packages resolve their deps through built `.d.ts`.
- #
- # `TURBO_RUN_SUMMARY` makes turbo write its run summary to `.turbo/runs/`
- # -- per task, the hash, the cache status and the tasks it depends on. It
- # changes nothing turbo runs or caches; `Report what the turbo cache
- # replayed` below reads it (objectui#12222). `TURBO_FORCE` is the string
- # `true` on the full battery only; turbo reads `false` as not forced.
- - name: Run type-check
- if: steps.relevant.outputs.should_run == 'true'
- env:
- TURBO_RUN_SUMMARY: 'true'
- TURBO_FORCE: ${{ steps.tc-scope.outputs.scope == 'full' }}
- SCOPE: ${{ steps.tc-scope.outputs.scope }}
- BASE: ${{ steps.tc-scope.outputs.base }}
- run: |
- if [ "$SCOPE" = 'affected' ]; then
- pnpm type-check "--filter=...[$BASE]"
- else
- pnpm type-check
- fi
-
- # ── The repo-root programs' packages, built on the fast speed (objectui#12272) ──
- # `Type-check repo-root vitest setup files` below resolves the workspace
- # packages the root `vitest.setup.*` files import through their BUILT
- # declarations. The full battery and the whole-workspace run build every
- # one of them as the `^build` of the packages that depend on them; the
- # affected run builds only the dependencies of what it selects, and that
- # may hold none of them -- a change outside every package selects ZERO
- # packages, so nothing built and that step failed with four TS2882 and a
- # TS2307 (PR #12262; and a change to `packages/plugin-ai` alone selects
- # one package and still leaves three of them unbuilt -- both measured on
- # objectui#12272, readings of that day, not live counts). So this
- # speed builds them here, whatever it selected. Which packages is read
- # on every run out of that program's tsconfig `paths` by the selector,
- # never copied. `turbo run build` replays each from the restored cache
- # when its hash did not move; it is not `turbo run type-check` with
- # their names added, because that task waits on `^build` -- the
- # DEPENDENCIES' builds -- and builds no named package's own (measured:
- # that spelling leaves `plugin-dashboard` and `plugin-grid` unbuilt).
- # No run summary is asked for here, so `Report what the turbo cache
- # replayed` still reads `Run type-check`'s; turbo's own `Cached:` line
- # goes out as a notice instead, so the replay stays readable to anyone
- # who cannot open this log. `push` and `schedule` skip this step: the
- # full battery is unchanged.
- - name: Build the packages the repo-root programs import
- if: >-
- steps.relevant.outputs.should_run == 'true'
- && steps.tc-scope.outputs.scope == 'affected'
- run: |
- set -o pipefail
- packages=$(node scripts/select-type-check-scope.mjs --root-builds)
- filters=()
- for p in $packages; do filters+=("--filter=$p"); done
- if [ "${#filters[@]}" -eq 0 ]; then
- echo 'The repo-root programs import no workspace package: nothing to build.'
- exit 0
- fi
- pnpm exec turbo run build "${filters[@]}" | tee "$RUNNER_TEMP/root-builds.out"
- cached=$(grep -E '^ *Cached:' "$RUNNER_TEMP/root-builds.out" | tail -n 1 | tr -s ' ' || true)
- echo "::notice title=Repo-root program builds::$(echo $packages) -- ${cached:-turbo printed no Cached line}"
-
- # The four repo-root `vitest.setup.*` files were in ZERO tsc programs
- # (objectui#3515) — the same shape as `scripts/` before objectui#3494, one
- # directory over. They live outside every workspace package, so
- # `pnpm type-check` above cannot reach them; the root `tsconfig.json`
- # includes only packages/examples/apps; and no consumer IMPORTS them (every
- # one names them as a Vitest `setupFiles` runtime path string), so no gated
- # program pulls them in transitively either.
- #
- # Placed after `pnpm type-check`, NOT next to `pnpm type-check:scripts`
- # above, and the difference is load-bearing. `tsconfig.scripts.json`'s
- # program imports no workspace package, so it can run straight after the
- # install; this one must, because `vitest.setup.dom.tsx` side-effect-imports
- # @object-ui/components, /fields, /plugin-dashboard and /plugin-grid for
- # their registrations. `tsconfig.vitest-setup.json` resolves those through
- # each package's own `exports.types`, i.e. its built declarations — which
- # exist only after the `^build` that `pnpm type-check` depends on, and on
- # the affected speed after the step above (objectui#12272).
- # `scripts/__tests__/vitest-setup-type-check.test.ts` pins that ordering,
- # `scripts/__tests__/select-type-check-scope.test.ts` the step above.
- #
- # A direct step, deliberately: this is not a turbo task, so no cache key
- # decides its verdict. `pnpm type-check` is cached by turbo and its `inputs`
- # would have to hash every root file a program reads (the objectui#3513
- # lesson); running this one directly sidesteps that class of half-armed
- # gate entirely, exactly as `type-check:scripts` does.
- - name: Type-check repo-root vitest setup files
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm type-check:vitest-setup
-
- # ── What the cache replayed (objectui#12222) ─────────────────────────
- # Bookkeeping, like the save below: it runs after the last checking step,
- # so it can neither delay nor change the verdict, and it is bounded and
- # `continue-on-error` for the reason the save is. It reads the run summary
- # `Run type-check` asked turbo for and prints, per task, what the
- # restored cache replayed and what re-ran -- each re-run task with the
- # tasks it depends on, because a task's hash folds theirs in, so the
- # dependency that moved every package's hash is named in the log. The
- # counts also go out as ONE `::notice` annotation: the check-runs API
- # serves annotations to readers who cannot open this log, so "the cache
- # replayed what the change did not touch" stays a thing anyone can
- # re-read on any run, never a figure written down once (AGENTS.md #9).
- # `!cancelled()` so a red type-check still reports what it replayed.
- - name: Report what the turbo cache replayed
- if: steps.relevant.outputs.should_run == 'true' && !cancelled()
- timeout-minutes: 2
- continue-on-error: true
- env:
- RESTORED_CACHE_KEY: ${{ steps.turbo-cache.outputs.cache-matched-key }}
- run: node scripts/report-turbo-cache.mjs
-
- # ── Only the full battery writes this cache, and only what it used ──
- # Measured on objectui#12222's first pull-request run: the cache it
- # restored (another job's, through the bare restore key the restore step
- # above no longer has) held 401 entries, about 2 GB, 321 of them unused
- # by the run -- and this job saved all of it again under its own key, on
- # every pull request, queue build and push. A store full of multi-GB
- # entries is one a size-limited cache store evicts (the eviction itself
- # is not readable from a seat's container: the cache API is refused
- # there). So the type-check cache is written by the full battery only
- # -- `push` and `schedule`, whose forced run covers every task -- and
- # this step first deletes every entry that run did not use, so `main`'s
- # entry is exactly the current verdicts and build outputs, the one every
- # pull request and queue build restores. Pull-request and queue runs
- # restore and never save. Bookkeeping like the save: bounded,
- # `continue-on-error`, after the last checking step;
- # `scripts/report-turbo-cache.mjs` refuses to prune on a summary that
- # lists no task.
- - name: Prune the turbo cache to what this run used
- if: >-
- steps.relevant.outputs.should_run == 'true'
- && (github.event_name == 'push' || github.event_name == 'schedule')
- timeout-minutes: 2
- continue-on-error: true
- run: node scripts/report-turbo-cache.mjs --prune
-
- # The bookkeeping half of the split documented at the restore step above,
- # placed HERE — after the last checking step — because that is exactly
- # where the post phase it replaces already ran. Nothing about which
- # commits this job accepts or rejects moves with it.
- #
- # `timeout-minutes: 5` is the bound the incident asked for: 300x the 1s a
- # healthy save of this cache measured, so it cannot fire on a working
- # runner, and even measured from the slowest verdict path on record
- # (6m53s, the incident run) it lands roughly eight minutes clear of this
- # job's 20-minute ceiling — this step can no longer reach that ceiling.
- #
- # `continue-on-error: true` is the other half, and without it the bound
- # would only trade a `cancelled` gate for a red one. A cache that failed
- # to upload costs the next run some time; it says nothing whatsoever
- # about the code under test, so it must not be allowed to speak for it.
- #
- # Behaviour preserved, spelled out so the equivalence is checkable:
- # - `cache-hit != 'true'` reproduces the combined action's own "exact
- # hit on the primary key ⇒ do not save" skip.
- # - the condition names no status function, so the implicit `success()`
- # still applies — matching the combined action's `post-if: success()`.
- # - same `path` and same `key` as the restore step above.
- # One deliberate change (objectui#12222): only `push` and `schedule`
- # save, after the prune step above; see there for why.
- - name: Save Turbo Cache
- if: >-
- steps.relevant.outputs.should_run == 'true'
- && steps.turbo-cache.outputs.cache-hit != 'true'
- && (github.event_name == 'push' || github.event_name == 'schedule')
- uses: actions/cache/save@v6
- timeout-minutes: 5
- continue-on-error: true
- with:
- path: .turbo/cache
- key: turbo-${{ runner.os }}-type-check-${{ github.sha }}
-
- # The pinned @objectstack build, saved only when this job built it (a cache
- # miss) and whatever the verdict above was -- it does not depend on it.
- # Bookkeeping: bounded and non-fatal (objectui#6577, objectui#7048). The bound
- # is not yet derived from a measured save; re-derive it from this step's runs.
- - name: Save the pinned @objectstack build
- if: ${{ !cancelled() && steps.relevant.outputs.should_run == 'true' && steps.objectstack-build.outcome == 'success' && steps.objectstack-pin.outputs.cache-hit != 'true' }}
- continue-on-error: true
- timeout-minutes: 2
- uses: actions/cache/save@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
-
- # PRs run the suite split across the runners of the `shard:` matrix below.
- # The suite is dominated by fixed per-file cost rather than by the
- # assertions: a PR run reported 495s wall
- # clock of which only 178s was `tests` — the rest was `setup` (831s
- # cumulative), `import` (199s) and `environment` (156s), each paid once per
- # test file because `isolate: true` re-executes the module graph per file
- # (vitest.config.mts explains why isolation has to stay on). That cost shards
- # near-linearly, and the fixed overhead of an extra runner here is only ~30s
- # (checkout + Node + a warm-pnpm-store install), so 4 shards cut wall clock
- # ~3x for ~26% more runner minutes.
- #
- # ⭐ 4 -> 8 (objectui#9499). At four shards this job had grown into its own
- # `timeout-minutes: 20`. Measured on the card over the 14 most recent
- # `merge_group` runs and the 14 most recent `pull_request` runs: the longest
- # SUCCEEDING shard-1 job was 1199 s against the 1200 s ceiling -- a ONE-SECOND
- # margin -- and two runs crossed it and were CANCELLED, one of them dequeuing
- # a pull request whose tests had already passed, because the merge queue
- # cannot tell `cancelled` from `failure`.
- #
- # ⛔ The ceiling is NOT raised (see `:641`, which ruled that out), ⛔ no test
- # is skipped, disabled or quarantined, and ⛔ the relevance gate is not
- # widened. The work is spread over twice the runners instead, and the one
- # step in this job that was never sharded at all -- the built-artifact pin
- # lane, which ran on shard 1 only and is what pushed the longest shard over --
- # moved out into `test-dist-pins` below.
- #
- # Doubling the matrix is only possible because the required-check set no
- # longer names the legs: `test-aggregate` below is the single required `Test`
- # context, so the width here is an ordinary workflow edit rather than a
- # repository-settings change that renames four live required contexts.
- #
- # ⭐ 8 -> 12 (objectui#12208, PR-1; ruling 6121816480, letter B). At eight
- # shards the job was back against the same `timeout-minutes: 20`: measured
- # on that card over the 1,728 shard jobs of the trailing seven days, the
- # shard wall sat at 14.4 min median, 17.4 min p95 and 20.3 min max. Same
- # remedy, same three refusals: ⛔ the ceiling is NOT raised, ⛔ no test is
- # skipped, and ⛔ the relevance gate is not widened -- the work is spread
- # over half again as many runners. Duration-balanced sharding is that
- # card's second pull request, not this edit. The width is one number on
- # both speeds (objectui#12214): the affected run on a pull request or queue
- # build and the full battery on `push` and `schedule` both use these same
- # legs, so `test-aggregate`'s `--shards` has one value to agree with.
- #
- # ⭐ Duration-balanced (objectui#12208, the same ruling's second pull
- # request). Vitest's own split hashed each file's path and cut the list into
- # equal FILE COUNTS, so which leg drew the heavy files was luck, and the
- # lucky-heavy leg is the one the cap cancels. The note on the `Run tests`
- # step below says what replaced it; `test-aggregate` prints the longest leg's
- # headroom on every run.
- #
- # ⭐ 12 -> 16 (objectui#12384; the same ruling's width rule, "the width
- # derived from the total measured duration at a target of about 10 minutes
- # per shard"). Balanced, the twelve legs were no longer heavy -- measured on
- # that card over the 50 hours after the balanced plan landed, 11.3 min
- # median and 12.6 min p99 per finished leg -- but the runners under them
- # were not all equally fast: the cap cancelled legs in 12 of the 279 runs
- # that ran tests, with no hang, every file on those VMs running 1.6x to 2.0x
- # slower than the same files on a normal one, episodically (13 of the 16
- # cancelled legs fell on one afternoon, 2026-10-14) and with no runner label
- # that tells such a VM apart. Twelve legs left a p99 leg 1.59x (20 / 12.6)
- # of slack, below that factor; sixteen legs carry 0.75 of the load each,
- # which moves the break-even slow factor to about 2.1x. The same three
- # refusals hold: ⛔ the ceiling is NOT raised, ⛔ no test is skipped, and ⛔
- # the relevance gate is not widened. A runner pin was not taken: there was
- # no measured runner class to pin. Whether the width still fits is
- # `test-aggregate`'s headroom reading on every run, not this comment.
- test:
- name: Test (shard ${{ matrix.shard }}/16)
- # Coverage instrumentation (v8 adds 40-100% overhead) is skipped on PRs;
- # the `test-coverage` job below runs the coverage lane on push.
- #
- # Written as "not push" rather than "is pull_request" (objectui#3523): the
- # third event this workflow now sees is `merge_group`, and a queue build
- # that runs no tests is the hole this repository just paid for. `push` is
- # still excluded because the coverage lane below is the push lane: it is
- # sharded of its own since objectui#5403 (4 ways, 8 since objectui#12256) --
- # ⛔ INDEPENDENTLY of the matrix above, which objectui#9499 widened to 8,
- # objectui#12208 to 12 and objectui#12384 to 16 without touching it: the
- # two lanes declare their own
- # `shard:` matrix and their own `--shard=N/M`, and the coverage blobs are
- # named `blob-N-M.json` by the coverage job's own flag -- and its
- # `coverage-report` job merges every shard's blob report into ONE complete
- # report, which is where the coverage thresholds are enforced (and, since
- # objectui#5436, where the report is published as an artifact rather than
- # uploaded to Codecov). PR and push behaviour is unchanged — only the
- # previously impossible third case moves.
- #
- # ⭐ Two speeds (objectui#12214): the event guard that kept this matrix off
- # `push` is GONE. Every event runs it now, at one of two speeds: on
- # `pull_request` and `merge_group` the `Select the tests this change
- # affects` step below narrows each shard to the affected set; on `push` and
- # `schedule` it is the whole suite, beside the coverage lane. The width
- # is the same on both speeds, so `test-aggregate` keeps one `--shards` and
- # still refuses a partial set.
- runs-on: ubuntu-latest
- # Bound the job so a stalled runner / non-exiting test worker fails fast and
- # is retryable, instead of hanging up to GitHub's 6h default.
- timeout-minutes: 20
-
- strategy:
- # Report every shard's failures, not just the first one to break.
- fail-fast: false
- matrix:
- shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v7
- with:
- submodules: true
- # `fetch-depth: 0` for the gate step below (objectui#3523): it diffs
- # against the merge base, which a depth-1 clone cannot resolve.
- fetch-depth: 0
-
- # Always report; run only when it matters — see the full note on the
- # `type-check` job above (objectui#3523). The exclusion list must stay
- # identical across the gated jobs of this workflow;
- # `scripts/__tests__/merge-queue-reporting.test.ts` fails if it drifts.
- - name: Decide whether this change needs a full run
- id: relevant
- run: |
- if [ "${{ github.event_name }}" != 'pull_request' ]; then
- echo 'should_run=true' >> "$GITHUB_OUTPUT"
- echo 'Not a pull request: push is filtered at the trigger, and a merge_group build is the last validation before main. Running everything.'
- exit 0
- fi
- if ! CHANGED=$(git diff --name-only \
- '${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}' -- \
- . \
- ':(exclude,glob)**/*.md' \
- ':(exclude,glob)content/**' \
- ':(exclude,glob)docs/**' \
- ':(exclude,glob)apps/site/**' \
- ':(exclude,glob).changeset/**'); then
- echo 'should_run=true' >> "$GITHUB_OUTPUT"
- echo 'Could not diff against the merge base. Running everything rather than skipping silently.'
- exit 0
- fi
- if [ -n "$CHANGED" ]; then
- echo 'should_run=true' >> "$GITHUB_OUTPUT"
- echo "$CHANGED"
- exit 0
- fi
- # Everything that changed is on the exclusion list above, and for the
- # `type-check` and `e2e` jobs that is the end of it. It is not the end
- # of it HERE, and objectui#8857 paid for the difference: a markdown
- # document can be a TEST'S INPUT. That pull request changed exactly one
- # file, `packages/plugin-dashboard/README.md`; the test that reads that
- # README extracts its fences and evaluates them. `Test (shard 1/4)`
- # reported success on the pull request page in ten seconds having run
- # nothing, the merge_group build then failed the same shard in 907
- # seconds, and the pull request was dequeued. The queue did its job;
- # the cost is that the signal arrived after review had concluded on a
- # green page.
- #
- # So this job asks one more question of the markdown the exclusions
- # above dropped: does a test READ it? `markdown-test-inputs.mjs` holds
- # the derived class -- which documents, and which test reads each --
- # and audits itself against the tree. Only THIS job carries the extra
- # stage.
- #
- # ⚠️ objectui#9241: the reason that used to stand here was an
- # ASSERTION -- "only this job runs the tests that read markdown
- # (measured: `type-check` reads no document, and no e2e spec does)".
- # Nothing had measured it. Both populations have now been run under
- # the same `fs` trace objectui#9096 used -- every `readFileSync` /
- # `openSync` / `promises.readFile` door recorded, the opened markdown
- # written out -- and the two answers differ:
- #
- # e2e ZERO. That job's Playwright suite opens no markdown
- # document at all. Stable across two runs, and in a run
- # of the same suite the same harness -- extension filter
- # moved off `.md` -- recorded reads from inside the
- # Playwright worker process, so the zero is a reading
- # and not a harness that never fired. ⚠️ The bound on
- # it: 23 of that job's 46 tests skip for want of a docs
- # site and a backend, in CI exactly as when this was
- # taken (`DOCS_BASE_URL` is set nowhere in this file and
- # that job starts no backend), so their bodies are
- # outside the reading. Static complement: no spec under
- # `e2e/` outside `record-header-title-width.spec.ts`
- # names `node:fs` at all, and that one ran.
- # type-check ⛔ NOT zero. Its `check:spec-symbols` gate reads EVERY
- # page under `content/docs/**` -- objectui#7995 widened
- # that gate onto published prose deliberately, after a
- # wrong spec claim shipped in
- # `content/docs/fields/lookup.mdx` (objectui#7537).
- # Those pages are excluded by the very pathspec above,
- # so a documentation-only pull request skips the gate
- # whose input it just edited -- the objectui#8857 shape,
- # one job over.
- #
- # ⛔ The second reading is a FINDING, not a licence to widen anything
- # here: objectui#9241 returns it for re-grading before a markdown
- # stage is wired into `type-check`, and this job's stage is unchanged
- # by it. Both readings are a SNAPSHOT -- re-take them with the trace,
- # do not trust this block.
- #
- # ⚠️ objectui#9096: that class is now TOTAL over markdown, and this
- # comment used to claim the opposite ("the widening is deliberately
- # narrow"). `scripts` joined the scan roots, and the gate tests under
- # `scripts/__tests__` read the documentation the way the product tests
- # read a README -- measured by running all 46 of them under an fs
- # trace, 1805 of 1805 tracked markdown files are opened by at least
- # one of them. ⇒ for THIS job, the markdown half of the exclusion list
- # above is now inert: any markdown-only pull request runs the shards.
- # Priced over the 513 first-parent commits available at the time: 36
- # reached this stage, 21 already ran, and the widening moves 14 of the
- # remaining 15 from skip to run -- 2.7pp of merges.
- #
- # ⛔ The rest of the exclusion list is NOT inert and must stay: a
- # non-markdown change under `content/**`, `docs/**` or `apps/site/**`
- # still skips. `8011852dc` is the live instance -- a site-only change
- # carrying no markdown, which skipped then and skips now.
- #
- # Same failure posture as the diff above -- a question that cannot be
- # answered means RUN. The script needs nothing but the checkout and
- # the runner's own node, so it is reachable here, before pnpm and
- # before install.
- if ! CHANGED=$(git diff --name-only \
- '${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}'); then
- echo 'should_run=true' >> "$GITHUB_OUTPUT"
- echo 'Could not diff against the merge base. Running everything rather than skipping silently.'
- exit 0
- fi
- if ! TEST_INPUTS=$(printf '%s\n' "$CHANGED" | node scripts/markdown-test-inputs.mjs --changed -); then
- echo 'should_run=true' >> "$GITHUB_OUTPUT"
- echo 'Could not tell whether the changed markdown is a test input. Running everything rather than skipping silently.'
- exit 0
- fi
- if [ -n "$TEST_INPUTS" ]; then
- echo 'should_run=true' >> "$GITHUB_OUTPUT"
- echo 'Markdown changed that a test reads as its input. Running everything:'
- echo "$TEST_INPUTS"
- else
- echo 'should_run=false' >> "$GITHUB_OUTPUT"
- echo 'Only ignored paths changed, and no test reads the markdown among them. Skipping the steps below; this check still reports.'
- fi
-
- - name: Enable Corepack and download the pinned pnpm
- if: steps.relevant.outputs.should_run == 'true'
- run: bash scripts/ci-setup-pnpm.sh
-
- - name: Verify pnpm version
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm --version
-
- - name: Setup Node.js
- if: steps.relevant.outputs.should_run == 'true'
- uses: actions/setup-node@v7
- with:
- node-version: '22.x'
- cache: 'pnpm'
-
- # objectui#12957: every @objectstack/* package resolves to the build of the
- # commit `.objectstack-sha` pins, read from node_modules/.cache/objectstack, so
- # it must be there before pnpm resolves anything. Restored by the pin and built
- # only on a miss (scripts/objectstack-pin.mjs); the save is this job's last step.
- - name: Restore the pinned @objectstack build
- id: objectstack-pin
- if: steps.relevant.outputs.should_run == 'true'
- uses: actions/cache/restore@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
-
- - name: Build @objectstack at the pin (skips a restored build)
- id: objectstack-build
- if: steps.relevant.outputs.should_run == 'true'
- run: node scripts/objectstack-pin.mjs build
-
- - name: Install dependencies
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm install --frozen-lockfile
-
- # ── The two speeds (objectui#12214, objectstack#22998) ──────────────
- # `push` and `schedule` are the full battery: nothing is selected, every
- # test file runs. On `pull_request` and `merge_group` the selector names
- # the affected set -- the packages the change sits in and every package
- # that depends on them, plus the test files the package graph cannot
- # place, which run on every change -- or answers `mode=full` when the
- # change reaches something every test runs through. Its header states the
- # rules and what it does not see; the full battery on `main` is the
- # backstop for every miss.
- #
- # Fails OPEN, like every gate in this file: a selector that exits
- # non-zero, prints no verdict, or is handed no base sha means the full
- # suite, never a skip. The exit status is captured beside the output, not
- # through a command substitution (objectui#6221). Every shard computes the
- # same selection from the same checkout, so `--shard` partitions one set.
- - name: Select the tests this change affects
- id: select
- if: steps.relevant.outputs.should_run == 'true'
- env:
- EVENT: ${{ github.event_name }}
- PR_BASE: ${{ github.event.pull_request.base.sha }}
- QUEUE_BASE: ${{ github.event.merge_group.base_sha }}
- run: |
- case "$EVENT" in
- pull_request) base="$PR_BASE" ;;
- merge_group) base="$QUEUE_BASE" ;;
- *)
- echo 'mode=full' >> "$GITHUB_OUTPUT"
- echo "A $EVENT run is the full battery: every test file, sixteen shards."
- exit 0
- ;;
- esac
- status=0
- if [ -z "$base" ]; then
- echo "::warning::no base sha on this $EVENT event; running the full suite rather than guessing."
- status=2
- else
- node scripts/select-affected-tests.mjs --base "$base" --out "$RUNNER_TEMP/affected-tests.txt" \
- > "$RUNNER_TEMP/select.out" || status=$?
- cat "$RUNNER_TEMP/select.out"
- fi
- verdict=''
- if [ "$status" -eq 0 ]; then
- verdict=$(tail -n 1 "$RUNNER_TEMP/select.out")
- fi
- case "$verdict" in
- mode=affected | mode=full) ;;
- *)
- echo "::warning::the affected-test selector reached no verdict (exit $status); running the full suite rather than skipping silently."
- verdict='mode=full'
- ;;
- esac
- echo "$verdict" >> "$GITHUB_OUTPUT"
-
- # Run the canonical root Vitest project once. Running `turbo run test`
- # here starts one Vitest process per package; several package configs
- # intentionally inherit the root monorepo project list, so CI ends up
- # repeating large chunks of the suite and can starve slower plugin tests.
- # The affected speed is the same root run with the selector's filters as
- # positionals -- paths relative to the repository root, no `--`, which is
- # the only spelling `scripts/vitest-invocation-guard.mjs` admits.
- #
- # `$MODE` is the VALUE of the step output `mode`: the selector step's
- # `mode=affected` line is GitHub's `key=value` form, so what arrives here
- # is `affected` or `full`, never the line itself. Comparing against the
- # whole line kept this speed from ever running (objectui#13350);
- # `scripts/__tests__/select-affected-tests-wiring.test.ts` runs both
- # steps as written, so neither end can be renamed alone.
- #
- # ⭐ Which files a leg runs is decided by measured per-file duration, not
- # by path hash (objectui#12208, ruling 6121816480, letter B): the root
- # config's `sequence.sequencer` is `scripts/test-shard-sequencer.mjs`,
- # and every leg computes the same plan from the same checkout. The full
- # speed also MEASURES: `scripts/test-file-timings.mjs` is added as a
- # third reporter and writes each file's whole cost on its worker (the
- # built-in json reporter's span leaves out import, setup and environment,
- # which is most of a file here). A CLI `--reporter` replaces vitest's
- # default set rather than adding to it, so the two it would have picked on
- # a runner, `default` and `github-actions`, are named beside it --
- # `scripts/__tests__/test-file-timings.test.ts` pins all three. The
- # writer never throws into the run: no report is one stderr line, never
- # a red leg.
- - name: Run tests (shard ${{ matrix.shard }}/16)
- if: steps.relevant.outputs.should_run == 'true'
- env:
- MODE: ${{ steps.select.outputs.mode }}
- OBJECTUI_TEST_TIMINGS_OUT: ${{ runner.temp }}/test-file-timings/shard-${{ matrix.shard }}.json
- run: |
- if [ "$MODE" = 'affected' ]; then
- mapfile -t filters < "$RUNNER_TEMP/affected-tests.txt"
- echo "Affected speed: ${#filters[@]} vitest filter(s), shard ${{ matrix.shard }} of 16."
- pnpm exec vitest run --shard=${{ matrix.shard }}/16 "${filters[@]}"
- else
- pnpm test --shard=${{ matrix.shard }}/16 --reporter=default --reporter=github-actions --reporter=./scripts/test-file-timings.mjs
- fi
-
- # The full battery's per-file timings, for the weekly refresh of
- # `scripts/test-shard-timings.json`
- # (`.github/workflows/test-shard-timings-refresh.yml`). Only `push` and
- # `schedule` upload: those are the runs the refresh reads, and an affected
- # run measures a slice. `!cancelled()` so a red leg still uploads what it
- # measured; the refresh takes only runs whose every leg was green.
- # `if-no-files-found: warn`, not `error`: the reading is report-only, and
- # a missing one must not turn a green leg red.
- - name: Upload this shard's per-file timings
- if: "!cancelled() && steps.relevant.outputs.should_run == 'true' && (github.event_name == 'push' || github.event_name == 'schedule')"
- uses: actions/upload-artifact@v7
- with:
- name: test-file-timings-${{ matrix.shard }}
- path: ${{ runner.temp }}/test-file-timings/
- retention-days: 7
- if-no-files-found: warn
-
- # The pinned @objectstack build, saved only when this job built it (a cache
- # miss) and whatever the verdict above was -- it does not depend on it.
- # Bookkeeping: bounded and non-fatal (objectui#6577, objectui#7048). The bound
- # is not yet derived from a measured save; re-derive it from this step's runs.
- - name: Save the pinned @objectstack build
- if: ${{ !cancelled() && steps.relevant.outputs.should_run == 'true' && steps.objectstack-build.outcome == 'success' && steps.objectstack-pin.outputs.cache-hit != 'true' }}
- continue-on-error: true
- timeout-minutes: 2
- uses: actions/cache/save@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
-
- # ── The built-artifact pin lane, lifted out of the shard matrix ──────────
- # (objectui#7183 for the lane itself; objectui#9499 for this job.)
- #
- # `pnpm test --shard=N/16` above resolves every workspace package to its `src`
- # -- that is what the root alias map is for -- so no test in it can observe
- # its own package's BUILT output. This is the only place a `dist`-importing
- # pin runs, and `turbo run test:dist` carries `dependsOn: ["build"]` for the
- # package under test, so the bundle exists before the pin reads it. Before
- # the lane existed such a pin could not be committed at all: it landed with
- # no `dist` to import, which is NOT MEASURED rather than a red pin, and the
- # usual repair (delete it, or let it skip) leaves a green suite that measures
- # nothing.
- #
- # ⭐ Why it is a JOB and not a step on shard 1 any more. It was never sharded
- # -- it ran whole, once, on shard 1 only -- so it made shard 1 structurally
- # the longest leg of a matrix bounded by `timeout-minutes: 20`. Measured on
- # objectui#9499 across 28 full CI runs it costs 31-58 s (median ~53 s), and
- # in BOTH observed ceiling kills the job died in this step with the test step
- # already green: the tests passed and the check reported `cancelled`. A
- # separate job pays for one more runner (~30 s of checkout + install) and
- # takes the asymmetry out of the matrix entirely, without touching a single
- # budget, skipping a single test, or widening the relevance gate.
- #
- # The relevance gate below is the `test` job's, verbatim including its
- # markdown second stage. ⛔ Not simplified: this lane used to run whenever
- # that job's gate said run, and a narrower gate here would silently stop
- # running the built-artifact pins on the markdown-only pull requests
- # objectui#9096 deliberately brought back into scope. The reasoning for every
- # line of it is on the `test` job above; it is not repeated here.
- test-dist-pins:
- name: Test (dist pins)
- # No event guard, like the shard matrix above (objectui#12214): the push
- # lane used to be the coverage lane alone, and this job skipped with the
- # shards. `test-aggregate` below asserts this job's result, so the two
- # still run together -- now on every event.
- runs-on: ubuntu-latest
- # The same bound the matrix carries. This job builds one package and runs a
- # handful of files, so it sits nowhere near it -- the bound is here for the
- # reason it is there, a stalled runner failing fast instead of hanging for
- # six hours.
- timeout-minutes: 20
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v7
- with:
- submodules: true
- # `fetch-depth: 0` for the gate step below (objectui#3523): it diffs
- # against the merge base, which a depth-1 clone cannot resolve.
- fetch-depth: 0
-
- - name: Decide whether this change needs a full run
- id: relevant
- run: |
- if [ "${{ github.event_name }}" != 'pull_request' ]; then
- echo 'should_run=true' >> "$GITHUB_OUTPUT"
- echo 'Not a pull request: push is filtered at the trigger, and a merge_group build is the last validation before main. Running everything.'
- exit 0
- fi
- if ! CHANGED=$(git diff --name-only \
- '${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}' -- \
- . \
- ':(exclude,glob)**/*.md' \
- ':(exclude,glob)content/**' \
- ':(exclude,glob)docs/**' \
- ':(exclude,glob)apps/site/**' \
- ':(exclude,glob).changeset/**'); then
- echo 'should_run=true' >> "$GITHUB_OUTPUT"
- echo 'Could not diff against the merge base. Running everything rather than skipping silently.'
- exit 0
- fi
- if [ -n "$CHANGED" ]; then
- echo 'should_run=true' >> "$GITHUB_OUTPUT"
- echo "$CHANGED"
- exit 0
- fi
- if ! CHANGED=$(git diff --name-only \
- '${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}'); then
- echo 'should_run=true' >> "$GITHUB_OUTPUT"
- echo 'Could not diff against the merge base. Running everything rather than skipping silently.'
- exit 0
- fi
- if ! TEST_INPUTS=$(printf '%s\n' "$CHANGED" | node scripts/markdown-test-inputs.mjs --changed -); then
- echo 'should_run=true' >> "$GITHUB_OUTPUT"
- echo 'Could not tell whether the changed markdown is a test input. Running everything rather than skipping silently.'
- exit 0
- fi
- if [ -n "$TEST_INPUTS" ]; then
- echo 'should_run=true' >> "$GITHUB_OUTPUT"
- echo 'Markdown changed that a test reads as its input. Running everything:'
- echo "$TEST_INPUTS"
- else
- echo 'should_run=false' >> "$GITHUB_OUTPUT"
- echo 'Only ignored paths changed, and no test reads the markdown among them. Skipping the steps below; this check still reports.'
- fi
-
- - name: Enable Corepack and download the pinned pnpm
- if: steps.relevant.outputs.should_run == 'true'
- run: bash scripts/ci-setup-pnpm.sh
-
- - name: Verify pnpm version
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm --version
-
- - name: Setup Node.js
- if: steps.relevant.outputs.should_run == 'true'
- uses: actions/setup-node@v7
- with:
- node-version: '22.x'
- cache: 'pnpm'
-
- # objectui#12957: every @objectstack/* package resolves to the build of the
- # commit `.objectstack-sha` pins, read from node_modules/.cache/objectstack, so
- # it must be there before pnpm resolves anything. Restored by the pin and built
- # only on a miss (scripts/objectstack-pin.mjs); the save is this job's last step.
- - name: Restore the pinned @objectstack build
- id: objectstack-pin
- if: steps.relevant.outputs.should_run == 'true'
- uses: actions/cache/restore@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
-
- - name: Build @objectstack at the pin (skips a restored build)
- id: objectstack-build
- if: steps.relevant.outputs.should_run == 'true'
- run: node scripts/objectstack-pin.mjs build
-
- - name: Install dependencies
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm install --frozen-lockfile
-
- - name: Run built-artifact pins (dist project)
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm test:dist
-
- # The pinned @objectstack build, saved only when this job built it (a cache
- # miss) and whatever the verdict above was -- it does not depend on it.
- # Bookkeeping: bounded and non-fatal (objectui#6577, objectui#7048). The bound
- # is not yet derived from a measured save; re-derive it from this step's runs.
- - name: Save the pinned @objectstack build
- if: ${{ !cancelled() && steps.relevant.outputs.should_run == 'true' && steps.objectstack-build.outcome == 'success' && steps.objectstack-pin.outputs.cache-hit != 'true' }}
- continue-on-error: true
- timeout-minutes: 2
- uses: actions/cache/save@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
-
- # ── ⭐ The single required test context (objectui#9499, ruling letter C) ──
- #
- # Until this job the required-check set named the matrix legs one by one --
- # `Test (shard 1/4)` … `Test (shard 4/4)` -- which welded the SHARD COUNT to
- # repository settings no workflow edit can reach. Changing 4 to 8 renames four
- # live required contexts: every pull request then blocks forever on a check
- # that can no longer report, and every queue build burns the ruleset's
- # 60-minute status-check timeout. One aggregator context instead means the
- # matrix width above is an ordinary workflow edit again.
- #
- # ⚠️ `needs.test.result` ALONE WOULD BE A PHANTOM GATE, and that is why this
- # job runs a script rather than reading an expression. A matrix job's
- # `needs..result` is a single ROLLUP over every leg, and a rollup cannot
- # distinguish "all sixteen ran green" from "thirteen ran green and three were
- # SKIPPED". A shard dropped from the matrix, or given a job-level `if:` that
- # is false, would leave this context GREEN while a fraction of the suite never
- # ran -- the objectui#8857 / objectui#3523 shape, a check reporting success
- # having run nothing. `scripts/check-test-shard-results.mjs` therefore reads
- # this run's own job list from the Actions API and asserts each shard BY NAME
- # and by its OWN conclusion, and it fails closed: an unreadable answer is a
- # red context, never a pass.
- #
- # `if: always()` so a red, skipped or cancelled shard still produces a verdict
- # here instead of skipping this job -- a skipped required context counts as
- # SUCCESS in branch protection, which on the one context that speaks for the
- # whole test lane is the failure mode itself. No event guard, matching the
- # two jobs above (objectui#12214): on `push` and `schedule` this is the full
- # battery's test verdict for the commit on `main`, read the same way.
- test-aggregate:
- name: Test
- needs: [test, test-dist-pins]
- if: always()
- runs-on: ubuntu-latest
- timeout-minutes: 10
- # Declaring any `permissions:` block sets every unlisted scope to `none`,
- # so both are named: `contents` for the checkout, `actions` for
- # `GET /actions/runs/{id}/attempts/{n}/jobs`. Nothing here writes.
- permissions:
- contents: read
- actions: read
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v7
-
- # No pnpm and no install: the gate is one `fetch` and runs on the
- # runner's own node, exactly as the relevance gates above do. That keeps
- # the one required test context cheap enough to report in seconds on a
- # pull request whose shards did no work.
- - name: Every test shard reported success, one shard at a time
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- # Printed, never trusted -- see the header above. Having the rollup in
- # the log next to the per-shard reading is what lets a future reader
- # check the claim rather than take it.
- TEST_MATRIX_ROLLUP: ${{ needs.test.result }}
- run: |
- echo "needs.test.result (the matrix rollup this gate deliberately does NOT trust) = $TEST_MATRIX_ROLLUP"
- node scripts/check-test-shard-results.mjs --shards 16 --job-name 'Test'
-
- # The dist-pin lane is NOT a matrix, so its `needs` result is that one
- # job's own conclusion and `skipped` is visible in it -- no API read is
- # needed to say anything true about it. It is asserted here rather than
- # left as a second required context so that the whole test lane still
- # speaks through one name.
- - name: The built-artifact pin job reported success
- env:
- DIST_PINS_RESULT: ${{ needs.test-dist-pins.result }}
- run: |
- if [ "$DIST_PINS_RESULT" != 'success' ]; then
- echo "::error title=Test::the test-dist-pins job reported '$DIST_PINS_RESULT', not 'success'"
- exit 1
- fi
- echo "test-dist-pins: success"
-
- # ⛔ REPORT-ONLY, not a gate (objectui#12208, ruling 6121816480;
- # objectstack#22998's timing rule): the longest shard's wall against
- # the `test` job's `timeout-minutes`, and the width this run's measured
- # total implies at the 10-minute target per shard. `always()` so a red
- # verdict above still gets its reading; `continue-on-error` so the
- # reading can never turn this context red, whatever it reads. The
- # arguments are pinned to the matrix and the cap by
- # `scripts/__tests__/shard-headroom.test.ts`.
- - name: The longest test shard's headroom against the cap (report-only)
- if: always()
- continue-on-error: true
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: node scripts/shard-headroom.mjs --job 'Test (shard {n}/16)' --shards 16 --cap-minutes 20 --target-minutes 10
-
- # Push to main/develop: the coverage lane, sharded 4 ways with a blob-report
- # merge (objectui#5403). The comment that used to sit here declined to shard
- # it — "nothing blocks on this job, so it isn't worth the blob report merge
- # that sharding a coverage run would require". That trade was priced when the
- # job was shorter. Re-measured on aa949ba2 — current `main`, and the FIRST
- # commit where objectui#5402's fix is in — with both arms on the same commit
- # and the same runner class (instrument: `proto-5403-shard-coverage.yml` on
- # branch `claude/issue-5403-proto`, runs 32387182880 and 32387778786;
- # elapsed from each job's own `started_at`/`completed_at`):
- #
- # 4 shards, slowest shard job 11 min 25 s / 10 min 52 s (two rounds)
- # + the merge job ~1 min 15 s (13 s of it the merge itself)
- # = the sharded lane ~12 min 40 s / ~12 min 07 s
- # the unsharded run 39 min 51 s of tests, 40 min 19 s of job
- #
- # ⚠️ The unblock comment on #5403 predicted that #5402 would have made the
- # unsharded arm FASTER than the 39 min 05 s measured before it. It has not:
- # 39 min 51 s on this commit, which is the old figure back within run-to-run
- # noise. And measured at the level the cap acts on — the JOB — it is 40 min
- # 19 s, i.e. `timeout-minutes: 40` would have killed this run about twenty
- # seconds before the suite finished. That is the fifth occurrence of a
- # timeout that has already fired four times, and a job killed at 40 minutes
- # uploads nothing at all. Unsharded, this lane is not merely slow; it no
- # longer reliably reaches its own upload step.
- #
- # Equivalence of the merged report is the acceptance test; it is measured
- # rather than assumed, and the numbers are in the pull request that made this
- # change — along with what happens on a red suite, which is the state `main`
- # is in on this commit.
- #
- # `timeout-minutes: 40` is deliberately UNCHANGED. It had already fired four
- # times on the unsharded job; at a quarter of the work per runner the margin
- # stops being tight on its own, and raising or lowering a cap is a separate
- # decision from this one.
- #
- # ⭐ 4 -> 8 (objectui#12256, folded into objectui#12208 under that card's
- # ruling 6121816480, letter B, by the director's fold note 6126024274). At
- # four shards the margin above had gone: measured on objectui#12256 over the
- # 25 newest `push` runs on `main`, a coverage shard was CANCELLED at the
- # 40-minute ceiling on 7 of them, and on the 18 that finished every shard ran
- # within one to three minutes of it. The width is derived from those walls,
- # not guessed: the four shards summed to about 91-153 minutes on the runs
- # that finished, and at a 20-minute target per shard that is eight. The same
- # three refusals as the `test` matrix: ⛔ the ceiling is NOT raised, ⛔ no test
- # is skipped or quarantined, and ⛔ `coverage.thresholds` are unchanged --
- # still enforced exactly once, by `coverage-report` below, over the merge of
- # EVERY shard's blob, which that job refuses to merge from a partial set. The
- # width is said once per job (the matrix here, `COVERAGE_SHARDS` there) and
- # `scripts/__tests__/coverage-shard-width.test.ts` holds every spelling of it
- # -- the job name, `--shard=N/M`, the blob name the merge counts and the
- # count it expects -- to this matrix.
- #
- # ## Why the shard legs override the coverage thresholds, and the merge does
- # ## not
- #
- # `coverage.thresholds` in `vitest.config.mts` is checked by the v8 provider's
- # `generateReports()` on EVERY coverage run — there is no shard-awareness
- # anywhere in that path (`@vitest/coverage-v8/dist/provider.js`: `if (this
- # .options.thresholds) await this.reportThresholds(...)`, reached from
- # `reportCoverage()` and from `mergeReports()` alike). Sharding without the
- # override would therefore evaluate the whole-suite thresholds once per shard
- # over a slice of the suite, plus once on the merged report (five times per
- # push at four shards, nine at eight). Those per-shard evaluations are
- # assertions this lane has never made, they judge partial data, and the only
- # verdict they can add is a false red. The override keeps the check at exactly
- # ONE evaluation over the complete report — the shape this lane had while it
- # was unsharded.
- #
- # ⚠️ It is NOT here because the shards were seen to fail: that prediction was
- # measured and refuted. A shard leg run WITHOUT the override passes today
- # (proto run 32387182880, "PROTO shard 1/4 WITHOUT the threshold override",
- # conclusion=success, reproduced in run 32387778786) — the v8 provider reports
- # only the files a run actually loaded, so one shard's percentage lands near
- # the whole suite's rather than at a quarter of it.
- #
- # That the gate still fires on the merged report is a code path, not a
- # guess: `mergeReports()` calls `generateReports(coverageMap, true)`, the
- # same function that ends in `if (this.options.thresholds) await this
- # .reportThresholds(...)`. ⚠️ The CI ablation that was meant to demonstrate it
- # end-to-end could not: on this commit the merged blobs carry a failing shard,
- # so both the enforced leg and the 99%-threshold leg go red for that reason
- # and neither separates the thresholds from the failures. Said here rather
- # than left as a claim the run does not actually support.
- test-coverage:
- name: Test (coverage shard ${{ matrix.shard }}/8)
- # The full battery's coverage half: every merge to `main` (`push`) and the
- # hourly `schedule` run (objectui#12214).
- if: github.event_name == 'push' || github.event_name == 'schedule'
- runs-on: ubuntu-latest
- timeout-minutes: 40
-
- strategy:
- # Report every shard's failures, not just the first one to break — and
- # the merge below refuses to publish anything unless every shard is green,
- # so there is nothing to save by stopping early.
- fail-fast: false
- matrix:
- shard: [1, 2, 3, 4, 5, 6, 7, 8]
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v7
- with:
- submodules: true
-
- - name: Enable Corepack and download the pinned pnpm
- run: bash scripts/ci-setup-pnpm.sh
-
- - name: Verify pnpm version
- run: pnpm --version
-
- - name: Setup Node.js
- uses: actions/setup-node@v7
- with:
- node-version: '22.x'
- cache: 'pnpm'
-
- # objectui#12957: every @objectstack/* package resolves to the build of the
- # commit `.objectstack-sha` pins, read from node_modules/.cache/objectstack, so
- # it must be there before pnpm resolves anything. Restored by the pin and built
- # only on a miss (scripts/objectstack-pin.mjs); the save is this job's last step.
- - name: Restore the pinned @objectstack build
- id: objectstack-pin
- uses: actions/cache/restore@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
-
- - name: Build @objectstack at the pin (skips a restored build)
- id: objectstack-build
- run: node scripts/objectstack-pin.mjs build
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- # `--reporter=blob` makes the run write `.vitest-reports/blob-N-8.json`
- # (the shard index is in the FILE NAME, which is what lets every shard's
- # artifact be downloaded into one directory below without colliding —
- # measured, not assumed). The blob carries this shard's raw coverage as
- # well as its test results; the coverage report proper is produced once,
- # by the merge.
- #
- # `--reporter=default --reporter=github-actions` are here because a CLI
- # `--reporter` REPLACES the reporter set rather than adding to it, and the
- # set it replaced is exactly this pair. Vitest 4.1.10 resolves reporters
- # with:
- #
- # if (!resolved.reporters.length) {
- # resolved.reporters.push([isAgent ? "agent" : "default", {}]);
- # if (process.env.GITHUB_ACTIONS === "true")
- # resolved.reporters.push(["github-actions", {}]);
- # }
- #
- # — and this repo's vitest config sets no `reporters`, so before
- # objectui#9177 the blob flag alone was the whole set. A red shard's log
- # therefore ended at `blob report written to …` with no failing test name,
- # no assertion text and no timeout message, and the shard job's only
- # annotation was the generic `Process completed with exit code 1.` The
- # failures existed solely inside the `coverage-blob-N` artifact, which is
- # download-only. objectui#8545 priced that: two episodes in which ONE test
- # file held main's coverage gate unevaluated for 84 and 87 consecutive
- # pushes, both found by a person reading a job log by hand, days later.
- #
- # ⚠️ Additive on purpose — the blob stays, because the merge job below is
- # what enforces the thresholds and it has nothing to read without it.
- # Measured on vitest 4.1.10 with all three reporters and `--shard=N/4`
- # (the width then; the reporter set does not depend on it):
- # the log carries `Failed Tests`, the test names, the assertion diff and
- # `Error: Test timed out in …`; `github-actions` emits one `::error`
- # annotation per failing test, which is the only form of this an API
- # reader gets without downloading an artifact; and the shard still writes
- # its `.vitest-reports/blob-N-M.json`, which merges and has the configured
- # thresholds enforced over it exactly as before.
- #
- # ⛔ Do not "simplify" this back to one reporter. Dropping the blob trades
- # an unreadable failure for an unevaluated coverage floor — the same
- # defect wearing the other hat — and dropping the other two restores
- # objectui#9177. `scripts/__tests__/coverage-shard-reporter-readability.test.ts`
- # fails in both directions.
- # ── Why this lane carries its own `--testTimeout` (objectui#9271) ────
- #
- # `vitest.config.mts` sets `testTimeout: 15000`. That budget is calibrated
- # for the UNINSTRUMENTED lane (`test:` above, which runs on every event
- # except push). This lane runs the same files under v8 coverage, and
- # instrumentation is not free: the repo-sweeping rows under
- # `scripts/__tests__/` — the ones that call `sweep(repoRoot)` /
- # `analyze(repoRoot)` and walk the whole tree — take several times longer
- # with the provider attached than without it.
- #
- # ⇒ the 15s budget has comfortable headroom uninstrumented and NONE here,
- # so rows that pass everywhere else die in this lane on a timeout. A dead
- # shard is not a failed gate, it is NO gate: `coverage-report` below gates
- # every merge step on `needs.test-coverage.result == 'success'`, so one
- # timed-out row skips the merge and the thresholds are never evaluated at
- # all. objectui#9271 measured 116 consecutive pushes to `main` in that
- # state; the comment above records objectui#8545 pricing two earlier
- # episodes of the same shape at 84 and 87 pushes.
- #
- # ⛔ This is NOT a performance budget and must not be read as one. The
- # timeout is a hung-test detector, and the detector that guards day-to-day
- # work is the 15s one in the uninstrumented lane — which is UNCHANGED, and
- # is the lane that actually runs before a merge. This number only stops
- # this lane from killing rows that are doing exactly what they should.
- #
- # ⭐ Re-derive the ratio rather than trusting a figure written here
- # (AGENTS.md #5 rule #9) — same tree, same runner, the flag set below as
- # the only variable:
- #
- # pnpm exec vitest run --project unit --testTimeout=900000 \
- # --reporter=verbose scripts/__tests__/check-i18n-dead-keys.test.ts
- # # then again with --coverage and compare the per-row ms
- #
- # ⚠️ Raising this cannot cost the job its own budget on its own: letting a
- # row finish instead of killing it at 15s adds only the difference, not a
- # re-run. The shard's own margin against `timeout-minutes: 40` is a
- # separate question -- objectui#12256 measured it gone at four shards,
- # which is why this lane is eight wide.
- - name: Run tests with coverage (shard ${{ matrix.shard }}/8)
- run: >-
- pnpm test:coverage --reporter=blob --reporter=default
- --reporter=github-actions --shard=${{ matrix.shard }}/8
- --testTimeout=60000
- --coverage.thresholds.lines=0 --coverage.thresholds.functions=0
- --coverage.thresholds.branches=0 --coverage.thresholds.statements=0
-
- # `always()`: a shard that went red is exactly when someone wants its
- # blob. It holds the failing results AND the coverage this runner
- # collected, and it survives a red run — which is why the `coverage`
- # directory being deleted on failure (vitest's `reportOnFailure: false`,
- # objectui#5402) stops mattering for this lane. `if-no-files-found: error`
- # because "the reporter wrote nothing" is a real finding, not a shrug.
- #
- # `retention-days: 7` is a deliberate, adjustable number rather than the
- # 90-day default: one blob per shard per full-battery run (eight since
- # objectui#12256, on every push and every hourly run) is several GB of
- # steady-state storage at a week -- the run's own artifact list is the
- # reading, not a figure written here (AGENTS.md #9). A week is what
- # makes a Monday-morning question about a Friday-evening red run
- # answerable; shorten it if the storage matters more than that.
- - name: Upload this shard's blob report
- if: always()
- uses: actions/upload-artifact@v7
- with:
- name: coverage-blob-${{ matrix.shard }}
- path: .vitest-reports/*
- include-hidden-files: true
- retention-days: 7
- if-no-files-found: error
-
- # The pinned @objectstack build, saved only when this job built it (a cache
- # miss) and whatever the verdict above was -- it does not depend on it.
- # Bookkeeping: bounded and non-fatal (objectui#6577, objectui#7048). The bound
- # is not yet derived from a measured save; re-derive it from this step's runs.
- - name: Save the pinned @objectstack build
- if: ${{ !cancelled() && steps.objectstack-build.outcome == 'success' && steps.objectstack-pin.outputs.cache-hit != 'true' }}
- continue-on-error: true
- timeout-minutes: 2
- uses: actions/cache/save@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
-
- # The other half of objectui#5403: merge every shard's blob report into ONE
- # complete coverage report, publish it, and — whatever happens — say out loud
- # whether this lane actually delivered for the commit.
- #
- # ## The Codecov upload is gone (objectui#5436, maintainer ruling 2026-08-22)
- #
- # `CODECOV_TOKEN` was never set on this repository, Codecov no longer accepts
- # tokenless uploads, and so `Upload coverage to Codecov` failed server-side
- # (`Token length: 0`, "Token required - not valid tokenless upload") on every
- # push after #5403 landed. Of the three ways out, the maintainer ruled Option
- # B: retire the upload rather than set the secret, and keep the merged report
- # as a build artifact plus the step summary below. What is given up is the
- # Codecov trend dashboard and its PR comments. What is NOT given up is the
- # GATE — the thresholds have always been enforced by the merge step, never by
- # Codecov, and that is untouched here. Making the upload conditional on the
- # secret being present was rejected: it restores exactly the quiet green that
- # #5403 removed.
- #
- # ## Why this job exists rather than an `if:` on one step
- #
- # The upload step used to carry no `if:` at all, so it inherited the implicit
- # `success()` and was SKIPPED whenever the suite failed: 311 of 373 coverage
- # jobs, and 120 of the 121 most recent. From outside, a lane that delivered
- # nothing and a lane that delivered an unchanged number look identical —
- # which is how four dark days went unnoticed while the job failed 100% of the
- # time (objectui#5402). Nothing blocks on this lane, so the fix cannot be
- # "block on it"; the fix is that the lane STATES its own outcome. #5436
- # carries that guarantee across to the claim that survives the upload's
- # retirement:
- #
- # this job is green ONLY when every shard passed, every shard's blob report
- # arrived, they merged into one report, the configured thresholds were
- # enforced over that whole merged map, and the report was published as a
- # retrievable artifact — and red, with an error annotation naming which of
- # those did not happen, every other time.
- #
- # That is a stronger claim than the one it replaces, not a weaker one. The
- # old red said "the dashboard is stale"; the new red says "the coverage GATE
- # did not run on this commit" — and since the shard legs override the
- # thresholds to zero, this job is the only place it can run at all.
- #
- # ⛔ The merge is deliberately NOT unconditional. A merged report built from
- # fewer than every shard would understate coverage on every file the missing
- # shard exercised, and a wrong coverage number is worse than a missing one.
- # So the merge and the publish run only when every shard is green AND every
- # shard's blob actually arrived; everything else takes the loud path.
- #
- # The job keeps the name `Test (coverage)` the unsharded job had: it is the
- # context that answers "did the coverage lane deliver for this commit", and
- # `scripts/dependabot-merge-gate.mjs` classifies it by that name.
- coverage-report:
- name: Test (coverage)
- needs: test-coverage
- # `always()` so the verdict step below still runs when a shard failed —
- # that is the entire point. The event guard matches `test-coverage`: the
- # full battery's events, `push` and `schedule` (objectui#12214).
- if: always() && (github.event_name == 'push' || github.event_name == 'schedule')
- runs-on: ubuntu-latest
- timeout-minutes: 20
- # The coverage matrix's width, said once for this job: the blob count the
- # merge refuses to go below and the verdict's wording both read it.
- # `scripts/__tests__/coverage-shard-width.test.ts` pins it to the
- # `test-coverage` matrix above (objectui#12256).
- env:
- COVERAGE_SHARDS: '8'
- # Declaring any `permissions:` block sets every unlisted scope to `none`,
- # so both are named: `contents` for the checkouts, `actions` for the
- # headroom reading's `GET /actions/runs/{id}/attempts/{n}/jobs`. The
- # artifact steps use the runner's own token, not this one. Nothing here
- # writes.
- permissions:
- contents: read
- actions: read
-
- steps:
- # Every step up to the verdict is gated on the shards having passed:
- # on a red run this job does a checkout-free ~10 seconds and reports.
- - name: Checkout code
- if: needs.test-coverage.result == 'success'
- uses: actions/checkout@v7
- with:
- submodules: true
-
- - name: Enable Corepack and download the pinned pnpm
- if: needs.test-coverage.result == 'success'
- run: bash scripts/ci-setup-pnpm.sh
-
- - name: Verify pnpm version
- if: needs.test-coverage.result == 'success'
- run: pnpm --version
-
- - name: Setup Node.js
- if: needs.test-coverage.result == 'success'
- uses: actions/setup-node@v7
- with:
- node-version: '22.x'
- cache: 'pnpm'
-
- # objectui#12957: every @objectstack/* package resolves to the build of the
- # commit `.objectstack-sha` pins, read from node_modules/.cache/objectstack, so
- # it must be there before pnpm resolves anything. Restored by the pin and built
- # only on a miss (scripts/objectstack-pin.mjs); the save is this job's last step.
- - name: Restore the pinned @objectstack build
- id: objectstack-pin
- if: needs.test-coverage.result == 'success'
- uses: actions/cache/restore@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
-
- - name: Build @objectstack at the pin (skips a restored build)
- id: objectstack-build
- if: needs.test-coverage.result == 'success'
- run: node scripts/objectstack-pin.mjs build
-
- - name: Install dependencies
- if: needs.test-coverage.result == 'success'
- run: pnpm install --frozen-lockfile
-
- - name: Download every shard's blob report
- if: needs.test-coverage.result == 'success'
- uses: actions/download-artifact@v8
- with:
- pattern: coverage-blob-*
- path: .vitest-reports
- merge-multiple: true
-
- # A zero is not a reading: `--merge-reports` is perfectly happy to merge
- # three blobs and print a confident, wrong total. Count them first.
- - name: Refuse to merge a partial set of shard reports
- id: blobs
- if: needs.test-coverage.result == 'success'
- run: |
- # `mkdir -p` first, and no `2>/dev/null` anywhere: a missing directory
- # must not abort this step before it can print the annotation below,
- # and a swallowed stderr is the fail-CLOSED spelling objectui#3723
- # removed from this file (pinned by merge-queue-reporting.test.ts).
- mkdir -p .vitest-reports
- ls -la .vitest-reports/
- blobs=$(find .vitest-reports -maxdepth 1 -type f -name "blob-*-${COVERAGE_SHARDS}.json" | wc -l)
- if [ "$blobs" -ne "$COVERAGE_SHARDS" ]; then
- echo "::error title=Coverage merge::expected $COVERAGE_SHARDS shard blob reports, found $blobs — a merged report built from a partial set understates coverage, refusing to merge"
- exit 1
- fi
- echo "all $COVERAGE_SHARDS shard blob reports present"
-
- # No tests run here: `--merge-reports` reads the blobs and produces the
- # one complete report. It is ALSO the gate. The configured
- # `coverage.thresholds` (vitest.config.mts: lines 40, functions 33,
- # branches 30, statements 40) are enforced HERE, over the whole merged
- # map, and a breach exits this step non-zero — the shard legs override
- # them to zero, so this step is the only place the gate runs at all.
- #
- # That the gate fires on a merged report is a code path, not a guess:
- # `mergeReports()` calls `generateReports(coverageMap, true)`, which ends
- # in `if (this.options.thresholds) await this.reportThresholds(...)`. It
- # is also measured, not asserted (objectui#5436): against one real blob
- # set reporting 38.4% lines, `--coverage.thresholds.lines=38` exits 0 and
- # `=39` exits 1 with `ERROR: Coverage for lines (38.4%) does not meet
- # global threshold (39%)`. Only the threshold moved between the two runs.
- # #5403's CI ablation could not separate the two legs (its blobs carried
- # a failing shard, so both went red for that reason), which is why the
- # demonstration was owed here.
- #
- # `generateReports()` writes the json reporter BEFORE it evaluates the
- # thresholds, which is what lets the publish step below still capture a
- # report that breached.
- - name: Merge the shard reports into one coverage report
- id: merge
- if: needs.test-coverage.result == 'success'
- run: pnpm test:coverage --merge-reports --coverage.reporter=json --coverage.reporter=text
-
- # What replaces the Codecov upload (objectui#5436): the merged report is
- # kept here, on the run, instead of on a third-party dashboard.
- #
- # `!cancelled()` plus an explicit `outcome != 'skipped'`, rather than the
- # implicit `success()` the steps above inherit: when the merge fails on
- # the THRESHOLDS, the report exists and is precisely the artifact someone
- # wants to open, so a red gate must still publish its evidence. The job
- # stays red either way — the verdict step below requires `merge=success`.
- #
- # `retention-days: 7` matches the shard blobs deliberately: this report is
- # derived from them, and outliving its own inputs would leave a number
- # with nothing left to check it against. `if-no-files-found: error`
- # because "the merge wrote no report" is a real finding, not a shrug.
- - name: Publish the merged coverage report
- id: report
- if: ${{ !cancelled() && needs.test-coverage.result == 'success' && steps.merge.outcome != 'skipped' }}
- uses: actions/upload-artifact@v7
- with:
- name: coverage-report
- path: coverage/
- retention-days: 7
- if-no-files-found: error
-
- # ⛔ REPORT-ONLY, not a gate (objectui#12208, ruling 6121816480;
- # objectui#12256): the longest coverage shard's wall against
- # `test-coverage`'s `timeout-minutes`, and the width this run's measured
- # total implies at the 20-minute target per shard. Before the verdict, so
- # the verdict stays this job's last word; `always()` so a red lane still
- # gets its reading -- a cancelled shard is precisely the one whose wall
- # is worth reading -- and `continue-on-error` so the reading can never
- # change the verdict. On a red lane the checkout above was skipped, so
- # this reads `scripts/` through a sparse checkout of its own. Pinned by
- # `scripts/__tests__/shard-headroom.test.ts`.
- - name: Check out the headroom reading's script
- if: always() && needs.test-coverage.result != 'success'
- uses: actions/checkout@v7
- with:
- sparse-checkout: scripts
-
- - name: The longest coverage shard's headroom against the cap (report-only)
- if: always()
- continue-on-error: true
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: node scripts/shard-headroom.mjs --job 'Test (coverage shard {n}/8)' --shards 8 --cap-minutes 40 --target-minutes 20
-
- # The loud part, inherited from objectui#5403 and re-aimed at the claim
- # that outlived the upload. Runs on every path — including the one where
- # every step above was skipped, which without this step would leave this
- # job GREEN while the coverage gate never ran.
- - name: Say out loud whether the coverage gate ran on this commit
- if: always()
- env:
- SHARDS: ${{ needs.test-coverage.result }}
- BLOBS: ${{ steps.blobs.outcome }}
- MERGE: ${{ steps.merge.outcome }}
- REPORT: ${{ steps.report.outcome }}
- run: |
- run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
-
- if [ "$SHARDS" = 'success' ] && [ "$BLOBS" = 'success' ] && [ "$MERGE" = 'success' ] && [ "$REPORT" = 'success' ]; then
- echo "::notice title=Coverage gate ran::the merged $COVERAGE_SHARDS-shard report was produced for $GITHUB_SHA, the thresholds passed over the whole map, and the report is attached as the coverage-report artifact"
- {
- echo "### Coverage: the gate ran on the complete merged report"
- echo
- echo "- commit: \`$GITHUB_SHA\`"
- echo "- shards: all $COVERAGE_SHARDS green, all $COVERAGE_SHARDS blob reports merged"
- echo "- thresholds: enforced over the whole merged map, passed"
- echo "- report: the \`coverage-report\` artifact on [this run]($run_url), kept 7 days"
- } >> "$GITHUB_STEP_SUMMARY"
- exit 0
- fi
-
- # The gate RAN and said no. Distinguished from the case below
- # because they call for opposite actions: this one is a real coverage
- # regression to fix, that one is a broken lane to repair.
- #
- # `report=success` is what separates them, and it is load-bearing
- # rather than decorative. `generateReports()` writes the configured
- # reporters in a loop and only THEN evaluates the thresholds, so a
- # breach leaves a complete `coverage/` behind and the publish step
- # succeeds; a merge that dies earlier (an unreadable blob, a version
- # mismatch) writes no report, `if-no-files-found: error` makes the
- # publish step red too, and this run falls through to the "gate did
- # not run" branch below — where it belongs. Measured on the pull
- # request for #5436: same blob set, `--coverage.thresholds.lines`
- # moved from 38 to 39 across the report's own 38.4%, exit 0 then
- # exit 1 with `ERROR: Coverage for lines (38.4%) does not meet global
- # threshold (39%)`, and a byte-identical `coverage-final.json` left
- # behind on BOTH legs.
- if [ "$MERGE" = 'failure' ] && [ "$REPORT" = 'success' ]; then
- echo "::error title=Coverage thresholds breached::shards=$SHARDS blobs=$BLOBS merge=failure report=$REPORT — the merged report for $GITHUB_SHA was produced and the configured thresholds were enforced over it, and they FAILED"
- {
- echo "### Coverage: the thresholds were enforced and BREACHED"
- echo
- echo "- commit: \`$GITHUB_SHA\`"
- echo "- shards: all $COVERAGE_SHARDS green, all $COVERAGE_SHARDS blob reports merged"
- echo "- thresholds: enforced over the whole merged map, **failed**"
- echo "- report published: \`$REPORT\`"
- echo
- echo "This is a coverage regression, not a broken lane. The merged"
- echo "report is the \`coverage-report\` artifact on [this run]($run_url);"
- echo "the merge step's log names the metric and the numbers."
- } >> "$GITHUB_STEP_SUMMARY"
- exit 1
- fi
-
- echo "::error title=Coverage gate did NOT run::shards=$SHARDS blobs=$BLOBS merge=$MERGE report=$REPORT — no complete merged report was produced for $GITHUB_SHA, so the coverage thresholds were never evaluated on this commit"
- {
- echo "### Coverage: the gate did NOT run on this commit"
- echo
- echo "- commit: \`$GITHUB_SHA\`"
- echo "- shard jobs: \`$SHARDS\`"
- echo "- all $COVERAGE_SHARDS blobs present: \`$BLOBS\`"
- echo "- merge + thresholds: \`$MERGE\`"
- echo "- report published: \`$REPORT\`"
- echo
- echo "The thresholds are enforced on the MERGED report and nowhere"
- echo "else — the shard legs override them to zero. So this is not a"
- echo "missing dashboard number: this commit reached the branch with"
- echo "its coverage gate unevaluated."
- } >> "$GITHUB_STEP_SUMMARY"
- exit 1
-
- # The pinned @objectstack build, saved only when this job built it (a cache
- # miss) and whatever the verdict above was -- it does not depend on it.
- # Bookkeeping: bounded and non-fatal (objectui#6577, objectui#7048). The bound
- # is not yet derived from a measured save; re-derive it from this step's runs.
- - name: Save the pinned @objectstack build
- if: ${{ !cancelled() && needs.test-coverage.result == 'success' && steps.objectstack-build.outcome == 'success' && steps.objectstack-pin.outputs.cache-hit != 'true' }}
- continue-on-error: true
- timeout-minutes: 2
- uses: actions/cache/save@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
-
- # Build + E2E merged into one job: avoids artifact upload/download (~30s)
- # and a duplicate `pnpm install` (~60s). This job only needs the console SPA
- # artifact for Playwright; package build and package-size checks are covered
- # by the Bundle Analysis workflow for package / console changes.
- e2e:
- name: Build & E2E
- runs-on: ubuntu-latest
- timeout-minutes: 30
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v7
- with:
- submodules: true
- # `fetch-depth: 0` for the gate step below (objectui#3523): it diffs
- # against the merge base, which a depth-1 clone cannot resolve.
- fetch-depth: 0
-
- # Always report; run only when it matters — see the full note on the
- # `type-check` job above (objectui#3523). The exclusion list must stay
- # identical across the gated jobs of this workflow;
- # `scripts/__tests__/merge-queue-reporting.test.ts` fails if it drifts.
- #
- # This job has no markdown second stage and, unlike `type-check`, that is
- # now a MEASUREMENT: objectui#9241 ran this job's suite under the `fs`
- # trace objectui#9096 built and it opened no markdown document, twice, in
- # a run where the same harness recorded non-markdown reads from inside the
- # Playwright worker. The bound on that zero, and the `type-check` reading
- # that did NOT come back zero, are on the `test` job's decide step above.
- - name: Decide whether this change needs a full run
- id: relevant
- run: |
- if [ "${{ github.event_name }}" != 'pull_request' ]; then
- echo 'should_run=true' >> "$GITHUB_OUTPUT"
- echo 'Not a pull request: push is filtered at the trigger, and a merge_group build is the last validation before main. Running everything.'
- exit 0
- fi
- if ! CHANGED=$(git diff --name-only \
- '${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}' -- \
- . \
- ':(exclude,glob)**/*.md' \
- ':(exclude,glob)content/**' \
- ':(exclude,glob)docs/**' \
- ':(exclude,glob)apps/site/**' \
- ':(exclude,glob).changeset/**'); then
- echo 'should_run=true' >> "$GITHUB_OUTPUT"
- echo 'Could not diff against the merge base. Running everything rather than skipping silently.'
- exit 0
- fi
- if [ -n "$CHANGED" ]; then
- echo 'should_run=true' >> "$GITHUB_OUTPUT"
- echo "$CHANGED"
- else
- echo 'should_run=false' >> "$GITHUB_OUTPUT"
- echo 'Only ignored paths changed. Skipping the steps below; this check still reports.'
- fi
-
- - name: Enable Corepack and download the pinned pnpm
- if: steps.relevant.outputs.should_run == 'true'
- run: bash scripts/ci-setup-pnpm.sh
-
- - name: Verify pnpm version
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm --version
-
- - name: Setup Node.js
- if: steps.relevant.outputs.should_run == 'true'
- uses: actions/setup-node@v7
- with:
- node-version: '22.x'
- cache: 'pnpm'
-
- # objectui#12957: every @objectstack/* package resolves to the build of the
- # commit `.objectstack-sha` pins, read from node_modules/.cache/objectstack, so
- # it must be there before pnpm resolves anything. Restored by the pin and built
- # only on a miss (scripts/objectstack-pin.mjs); the save is this job's last step.
- - name: Restore the pinned @objectstack build
- id: objectstack-pin
- if: steps.relevant.outputs.should_run == 'true'
- uses: actions/cache/restore@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
-
- - name: Build @objectstack at the pin (skips a restored build)
- id: objectstack-build
- if: steps.relevant.outputs.should_run == 'true'
- run: node scripts/objectstack-pin.mjs build
-
- - name: Install dependencies
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm install --frozen-lockfile
-
- - name: Build Console for E2E
- if: steps.relevant.outputs.should_run == 'true'
- # Pin VITE_BASE_PATH so the E2E suite (which mounts the SPA at
- # /console/) gets an absolute-base build whose asset URLs resolve
- # under that prefix. The default ('./') is correct for embedded
- # deployments but breaks vite preview's SPA fallback.
- #
- # Use Vite directly instead of `pnpm --filter @object-ui/console build`:
- # the package build runs `tsc` and `build:plugin`, which need built
- # workspace declaration files. The Vite bundle aliases workspace
- # packages to `src/` and is the only artifact Playwright consumes.
- env:
- VITE_BASE_PATH: /console/
- run: pnpm --filter @object-ui/console exec vite build
-
- - name: Verify build artifacts
- if: steps.relevant.outputs.should_run == 'true'
- run: |
- if [ ! -f "apps/console/dist/index.html" ]; then
- echo "Console build failed"
- exit 1
- fi
- echo "Console build artifact is ready"
-
- - name: Get Playwright version
- if: steps.relevant.outputs.should_run == 'true'
- id: playwright-version
- run: |
- # This pipeline's exit status is load-bearing: the value it produces
- # becomes the Playwright browser cache key in the next step. Without
- # `pipefail` a failing `pnpm list` is masked by `jq`, and `jq -r`
- # prints the string `null` and exits 0 when the field is missing — so
- # either failure used to yield a *successful* step and a key that had
- # silently degraded to `playwright-Linux-` / `playwright-Linux-null`.
- # That wrong bucket is stable, so two Playwright versions can share
- # one cache entry and restore a stale browser (objectui#6231).
- # The same block is duplicated verbatim in `ci.yml` and `live-e2e.yml`
- # — keep them byte-identical so they stay greppable as a pair.
- set -eo pipefail
- if ! version=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version'); then
- echo "::error::Reading the @playwright/test version failed (pnpm list --json | jq). Refusing to write a Playwright browser cache key from it."
- exit 1
- fi
- if [ -z "$version" ] || [ "$version" = "null" ]; then
- echo "::error::Could not resolve the @playwright/test version (got: '${version}'). Refusing to write an empty or null version into the Playwright browser cache key."
- exit 1
- fi
- echo "version=$version" >> "$GITHUB_OUTPUT"
-
- # ── Cache bookkeeping cannot void a recorded verdict (objectui#7048) ──
- # THE ORDERING, carried here from the type-check split (objectui#6577,
- # PR #7047) because it is what a future reader needs in order to judge
- # whether these steps may be touched: the verdict is recorded by the
- # checking steps; everything after them is bookkeeping, and bookkeeping
- # must never discard an answer the gate already produced.
- #
- # Why the SPLIT rather than a timeout on one step: combined
- # `actions/cache` declares `main: dist/restore/index.js` plus
- # `post: dist/save/index.js`, so its save is a step the RUNNER generates
- # at job end (`Post Cache Playwright browsers`). No workflow syntax
- # attaches `timeout-minutes` or `continue-on-error` to a generated post
- # step, so an upload stall runs the job into `timeout-minutes: 30`, the
- # job goes `cancelled`, and the merge queue cannot tell `cancelled` from
- # `failure` — an all-green pull request is ejected and every lane pays
- # the ceiling in head-of-line blocking. Measured once, on the type-check
- # cache: a 1-second save took 789s (objectui#6577). Splitting moves the
- # save into the main phase, where both keys are ordinary documented step
- # keys; `actions/cache`'s own `save-always` deprecation text points at
- # this same split.
- #
- # ⛔ Raising `timeout-minutes: 30` is the ruled-out non-fix: a larger
- # ceiling only buys a longer hang, still ends in `cancelled`, and lifting
- # a gate's ceiling weakens the gate.
- #
- # The restore half is deliberately left UNBOUNDED, as at the type-check
- # site: a restore stall fails BEFORE any verdict exists — a gate that did
- # not run, which is honest — rather than a recorded verdict discarded.
- - name: Restore Playwright browsers
- if: steps.relevant.outputs.should_run == 'true'
- uses: actions/cache/restore@v6
- id: playwright-cache
- with:
- path: ~/.cache/ms-playwright
- key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
-
- # ── No `apt-get` on this path any more (objectui#5304) ────────────────
- # `playwright install --with-deps` / `install-deps` shell out to
- # `apt-get update` as root. On 2026-08-19 that hung this job three times,
- # each for the full 30-minute ceiling, with a byte-identical last line —
- # `Get:5 https://archive.ubuntu.com/ubuntu noble-security InRelease
- # [126 kB]` — after the runner's Azure mirror answered `Ign:` for all four
- # noble indexes and apt fell back to archive.ubuntu.com:
- # #5290 job 95986117935 07:05:48 -> cancelled 07:34:39 (30m14s)
- # #5290 job 95993643751 07:38:31 -> cancelled 08:07:32 (30m18s, re-run)
- # #5294 job 95988703670 07:17:27 -> cancelled 07:46:21 (30m17s)
- # apt has no timeout covering a mirror that connects and then trickles, so
- # the only backstop was `timeout-minutes: 30` — which converts a transient
- # network fault into a CANCELLED check: a gate that reports nothing at all.
- #
- # A transient, not a property of the workflow: on #5290 the
- # `Live E2E (informational)` job (95986118258) ran the same `install-deps`
- # on the same commit in the same minute and went green in 3m26s. Which is
- # why the call has to be bounded rather than trusted.
- #
- # Browser binaries come from the cache step above, never from apt
- # (measured on the run that then hung: `Cache hit for:
- # playwright-Linux-1.62.1`, 269 MB restored). `install` without
- # `--with-deps` fetches them from Playwright's CDN and touches no mirror,
- # so the cache-miss path is safe as spelled.
- - name: Install Playwright browsers
- if: steps.relevant.outputs.should_run == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
- run: pnpm exec playwright install chromium
-
- # The system libraries are on the `ubuntu-latest` image already — it ships
- # Chrome, Edge and Firefox, whose shared-library closure is the one
- # `install-deps chromium` asks for. Rather than assume that, this step
- # PROVES it on every run by launching the same bundled Chromium the suite
- # launches, and reaches for apt only if the launch fails — bounded, so a
- # stalled mirror costs seconds instead of the job. It is not a softener:
- # if no browser can be made to launch the script exits non-zero and this
- # job goes red. Rationale and the incident log: the script's header;
- # behaviour pinned by `scripts/__tests__/ensure-chromium-ready.test.ts`.
- - name: Ensure Chromium can launch
- if: steps.relevant.outputs.should_run == 'true'
- run: bash scripts/ensure-chromium-ready.sh
-
- - name: Run E2E tests
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm test:e2e --project=chromium
-
- # `playwright.config.ts` selects the `github` reporter when CI is set, and
- # that reporter writes annotations only — it produces NO
- # `playwright-report/` directory, so this step uploaded nothing and said
- # so in a warning rather than a failure: `No files were found with the
- # provided path: playwright-report/` (objectui#4086). Every failure's
- # actual evidence — screenshot, trace and `error-context.md` — is written
- # by the `use.screenshot` / `use.trace` settings into `test-results/`
- # instead, which is why a red E2E job left nothing behind to diagnose and
- # #4086 had to be reproduced from scratch locally.
- #
- # Both paths are listed: `test-results/` is the one that exists today, and
- # `playwright-report/` keeps working if the HTML reporter is ever enabled
- # on CI. upload-artifact only warns when NO path matches, so the absent
- # one costs nothing.
- - name: Upload failure artefacts
- uses: actions/upload-artifact@v7
- if: ${{ steps.relevant.outputs.should_run == 'true' && !cancelled() && failure() }}
- with:
- name: e2e-failure-artifacts
- path: |
- playwright-report/
- test-results/
- retention-days: 14
-
- # The bookkeeping half of the split documented at the restore step above,
- # placed HERE — last in the job — because that is exactly where the post
- # phase it replaces already ran. Nothing about which commits this job
- # accepts or rejects moves with it.
- #
- # `timeout-minutes: 8` is DERIVED FOR THIS CACHE, not inherited from the
- # type-check site's 5 (objectui#7048 fences that explicitly). This cache
- # is Playwright's browser binaries — ~269 MB, two orders of magnitude
- # larger than `.turbo/cache` — and upload time scales with size, so the
- # bound has to be sized against a measurement of THIS artefact:
- # - restores of this exact archive, same runner class: 3s (job
- # 100096569775) and 3s (job 100094597323) here, 5s in `live-e2e.yml`
- # (job 100097466897) ⇒ the cache service moves it at ~54-90 MB/s.
- # - a save is only observable after a Playwright version bump (an exact
- # key hit skips it, and `Post Cache Playwright browsers` measured 0s
- # on every sampled run), so the honest save is bounded from the
- # restore rather than read directly — stated plainly rather than
- # dressed up as a direct measurement.
- # - taking a deliberately pessimistic 20x penalty on the slowest
- # observed restore gives ~1m40s for an honest save. 8 minutes is
- # ~4.8x that, and demands only 0.56 MB/s sustained to finish.
- # And it stays clear of the ceiling in both directions: 8 x 2 = 16 <= 30,
- # so bookkeeping cannot crowd out the checking steps, whose whole verdict
- # path measured 56s wall clock end to end on job 100096569775.
- #
- # `continue-on-error: true` is the other half, and without it the bound
- # would only trade a `cancelled` gate for a red one. A cache that failed
- # to upload costs the next run a re-download from Playwright's CDN — a
- # path this job already takes on a miss — and says nothing whatsoever
- # about the code under test, so it must not be allowed to speak for it.
- #
- # Behaviour preserved, spelled out so the equivalence is checkable:
- # - `cache-hit != 'true'` reproduces the combined action's own "exact
- # hit on the primary key ⇒ do not save" skip.
- # - the condition names no status function, so the implicit `success()`
- # still applies — matching the combined action's `post-if: success()`.
- # - same `path` and same `key` as the restore step above.
- - name: Save Playwright browsers
- if: >-
- steps.relevant.outputs.should_run == 'true'
- && steps.playwright-cache.outputs.cache-hit != 'true'
- uses: actions/cache/save@v6
- timeout-minutes: 8
- continue-on-error: true
- with:
- path: ~/.cache/ms-playwright
- key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
-
- # The pinned @objectstack build, saved only when this job built it (a cache
- # miss) and whatever the verdict above was -- it does not depend on it.
- # Bookkeeping: bounded and non-fatal (objectui#6577, objectui#7048). The bound
- # is not yet derived from a measured save; re-derive it from this step's runs.
- - name: Save the pinned @objectstack build
- if: ${{ !cancelled() && steps.relevant.outputs.should_run == 'true' && steps.objectstack-build.outcome == 'success' && steps.objectstack-pin.outputs.cache-hit != 'true' }}
- continue-on-error: true
- timeout-minutes: 2
- uses: actions/cache/save@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
-
- docs:
- name: Build Docs
- runs-on: ubuntu-latest
- timeout-minutes: 15
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v7
- with:
- fetch-depth: 0
- submodules: true
-
- # Fails OPEN: if the diff cannot be computed the job builds the site,
- # rather than reporting green having built nothing (objectstack#4928).
- # Until objectui#3723 this step swallowed a failed `git diff` into an empty
- # result (`2>/dev/null || echo ""`), which is indistinguishable from
- # "nothing docs-related changed" — a checkout that did not fetch deep
- # enough, a transient git failure or a malformed sha skipped the whole
- # site build and the job still reported success. Measured against a
- # fixture repository: on an unreachable base sha the old spelling yielded
- # `should_run=false`, this one yields `true`; on a reachable base sha both
- # agree, so which pull requests pay for a site build is unchanged.
- - name: Check for docs changes
- id: docs-changes
- run: |
- # `!= pull_request` covers `push` and `merge_group` alike
- # (objectui#3523). A queue build has no `github.event.pull_request`,
- # so the diff below would run on an empty revision range — and that
- # case is NOT caught by failing open: git reads a bare `...` as
- # `HEAD...HEAD` and exits 0 with no output (measured), so it would
- # skip the site build on the last check before `main`. This early
- # return is what covers it.
- if [ "${{ github.event_name }}" != 'pull_request' ]; then
- echo 'should_run=true' >> "$GITHUB_OUTPUT"
- echo 'Not a pull request: push is filtered at the trigger, and a merge_group build is the last validation before main. Building the site.'
- exit 0
- fi
- # ── What this job validates, and therefore what must trigger it ──
- # (objectui#8647). Until then this list was `apps/site/` +
- # `content/` alone, which is the *output* surface of the site, not
- # its *input* surface: `pnpm turbo run build --filter='@object-ui/site'`
- # builds the whole workspace closure before it runs `next build`
- # (measured here: 30 tasks, 2m28s with an empty turbo cache), so a
- # change to any of them changes what this job compiles. A pull
- # request touching only `packages/**` therefore reported `success`
- # in seconds having built nothing — and a skipped build and a passed
- # build are the same green to every reader downstream. That is not
- # hypothetical for this repository: objectui#8344's getter-binding
- # route was green on rollup and red on Next/Turbopack, which is why
- # a green here is written into card acceptance criteria at all.
- #
- # ⭐ The list below is DERIVED, not curated, and
- # `scripts/__tests__/docs-build-trigger.test.ts` re-derives it on
- # every pull request and fails when this list stops covering it:
- # - one entry per workspace package in the site's build closure —
- # the package-manifest graph reached from `@object-ui/site` over
- # `dependencies`, `devDependencies` and `peerDependencies` on the
- # `workspace:` protocol, which is the graph turbo walks for
- # `^build` (measured against `turbo run build
- # --filter='@object-ui/site' --dry=json`: 31 entries = these 30
- # packages plus the site; one of them, `packages/test-support`,
- # declares no `build` script, so 30 tasks actually execute);
- # - one entry per root-level input `turbo.json` declares for the
- # `build` task (`$TURBO_ROOT$/...`), because turbo names them as
- # things a build's output depends on;
- # - the four workspace manifests, since they decide which versions
- # of everything the build resolves.
- # ⇒ Adding a dependency to any of these packages, or a root build
- # input to `turbo.json`, turns that test red rather than quietly
- # narrowing this gate again.
- #
- # One entry is deliberate rather than derived: this workflow file.
- # It is what decides whether and how the site is built, so a pull
- # request that edits it should be validated by the very job it
- # edits — the alternative is changing this gate and getting the
- # 13-second green as the acceptance evidence for the change. It is
- # not `.github/` wholesale: nothing else under there feeds this
- # build, and the pin asserts an unrelated workflow still skips.
- # Measured over the same 400: 2 of them touch this file.
- #
- # What is deliberately NOT here, priced over the 400 most recent
- # merged pull requests: the 11 workspace packages outside the
- # closure (`app-shell`, `auth`, `cli`, `collaboration`,
- # `create-plugin`, `plugin-ai`, `plugin-designer`, `plugin-report`,
- # `plugin-tree`, `runner`, `vscode-extension`) — including them
- # would fire on a further 15 points of pull requests and cannot
- # change what `next build` compiles; and `scripts/**` beyond the
- # entries turbo itself declares, a further 13 points for the same
- # reason. Cost of the list as it stands, measured over those 400 by
- # replaying this exact pathspec against each merge: it fires on 281
- # (70%) where the old list fired on 54 (13.5%) — 227 (56.8%) newly
- # pay for a site build. ⛔ Narrowing it by excluding the tooling
- # files under those packages (238/400, a further 11 points, sound
- # today because every published build program excludes tooling
- # directories) was considered and declined: this gate's whole defect
- # class is under-firing silently, and an exclusion is a second thing
- # that has to stay true for it not to recur.
- if ! CHANGED=$(git diff --name-only \
- '${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}' -- \
- '.github/workflows/ci.yml' \
- 'apps/site/' \
- 'content/' \
- 'examples/schema-catalog/' \
- 'packages/components/' \
- 'packages/core/' \
- 'packages/data-objectstack/' \
- 'packages/fields/' \
- 'packages/i18n/' \
- 'packages/layout/' \
- 'packages/mobile/' \
- 'packages/permissions/' \
- 'packages/plugin-calendar/' \
- 'packages/plugin-charts/' \
- 'packages/plugin-chatbot/' \
- 'packages/plugin-dashboard/' \
- 'packages/plugin-detail/' \
- 'packages/plugin-editor/' \
- 'packages/plugin-form/' \
- 'packages/plugin-gantt/' \
- 'packages/plugin-grid/' \
- 'packages/plugin-kanban/' \
- 'packages/plugin-list/' \
- 'packages/plugin-map/' \
- 'packages/plugin-markdown/' \
- 'packages/plugin-timeline/' \
- 'packages/plugin-view/' \
- 'packages/providers/' \
- 'packages/react/' \
- 'packages/react-runtime/' \
- 'packages/sdui-parser/' \
- 'packages/test-support/' \
- 'packages/types/' \
- 'scripts/build-plugin-stylesheet.mjs' \
- 'scripts/check-dist-completeness.mjs' \
- 'scripts/invoked-as.mjs' \
- 'scripts/vite-*.ts' \
- 'scripts/vitest-invocation-guard.mjs' \
- 'tsconfig.json' \
- 'tsconfig.tier-base.json' \
- 'package.json' \
- 'pnpm-lock.yaml' \
- 'pnpm-workspace.yaml' \
- 'turbo.json'); then
- echo 'should_run=true' >> "$GITHUB_OUTPUT"
- echo 'Could not diff against the merge base. Building the site rather than skipping silently.'
- exit 0
- fi
- if [ -n "$CHANGED" ]; then
- echo 'should_run=true' >> "$GITHUB_OUTPUT"
- echo 'Files the site build consumes changed:'
- echo "$CHANGED"
- else
- echo 'should_run=false' >> "$GITHUB_OUTPUT"
- echo 'Nothing the site build consumes changed. Skipping the steps below; this check still reports.'
- fi
-
- - name: Enable Corepack and download the pinned pnpm
- if: steps.docs-changes.outputs.should_run == 'true'
- run: bash scripts/ci-setup-pnpm.sh
-
- - name: Verify pnpm version
- if: steps.docs-changes.outputs.should_run == 'true'
- run: pnpm --version
-
- - name: Setup Node.js
- if: steps.docs-changes.outputs.should_run == 'true'
- uses: actions/setup-node@v7
- with:
- node-version: '22.x'
- cache: 'pnpm'
-
- # The docs *link* check is deliberately NOT here any more — do not add it
- # back. `node scripts/check-doc-links.mjs` ran in this job from #3213 /
- # #3292 (PR #3450) until #3448 promoted it to its own workflow,
- # `docs-links.yml` (a step of `repo-gates.yml` since objectui#12214), with no
- # path filters. The reason THEN was this
- # workflow's own `paths-ignore`, which at the time listed `content/**` and
- # `'**/*.md'` on both triggers: GitHub has no per-job path filter, so a
- # docs-ONLY pull request never started `ci.yml` at all and the link check
- # never saw the class of PR most likely to break a link. Since
- # objectui#3523 step 2 that filter is gone from `pull_request` and survives
- # only on `push` (objectui#3857), so the pull-request half of that
- # blindness is gone — the push half is not, and this job still does not run
- # on a docs-only push to `main`. objectui#4381 corrected the present tense
- # this note used to carry. It stays removed rather than kept in both places
- # either way: the standalone workflow's trigger set is a strict superset of
- # this job's, so a copy here could only ever add a second red check for the
- # same broken link, and a second place to forget.
- # `scripts/__tests__/docs-links-workflow.test.ts` pins the gate to exactly
- # one home.
-
- # ── Cache bookkeeping cannot void a recorded verdict (objectui#7048) ──
- # THE ORDERING, carried here from the type-check split (objectui#6577,
- # PR #7047) because it is what a future reader needs in order to judge
- # whether these steps may be touched: the verdict is recorded by the
- # checking steps; everything after them is bookkeeping, and bookkeeping
- # must never discard an answer the gate already produced.
- #
- # Why the SPLIT rather than a timeout on one step: combined
- # `actions/cache` declares `main: dist/restore/index.js` plus
- # `post: dist/save/index.js`, so its save is a step the RUNNER generates
- # at job end (`Post Turbo Cache`). No workflow syntax attaches
- # `timeout-minutes` or `continue-on-error` to a generated post step, so
- # an upload stall runs the job into `timeout-minutes: 15` — the TIGHTEST
- # ceiling of this workflow's three cached jobs — the job goes
- # `cancelled`, and the merge queue cannot tell `cancelled` from
- # `failure`. Measured once, on the type-check cache: a 1-second save took
- # 789s and ejected an all-green pull request (objectui#6577).
- # `actions/cache`'s own `save-always` deprecation text points at this
- # same split.
- #
- # ⛔ Raising `timeout-minutes: 15` is the ruled-out non-fix: a larger
- # ceiling only buys a longer hang, still ends in `cancelled`, and lifting
- # a gate's ceiling weakens the gate.
- #
- # The restore half is deliberately left UNBOUNDED: a restore stall fails
- # BEFORE any verdict exists — a gate that did not run, which is honest —
- # rather than a recorded verdict discarded.
- #
- # ONE restore key, this job's own prefix (objectui#12276, the shape the
- # `type-check` job above took in objectui#12222). Until then a bare
- # `turbo--` key followed this one, and when the job's own prefix
- # found nothing it matched any job's cache -- `Lint`'s, the bundle and
- # release builds' -- which this job then saved again under its own key.
- # Pull-request and queue runs save nothing now (see the prune step
- # below), so on those refs this key falls through to `main`'s entry.
- - name: Restore Turbo Cache
- id: turbo-cache
- if: steps.docs-changes.outputs.should_run == 'true'
- uses: actions/cache/restore@v6
- with:
- path: .turbo/cache
- key: turbo-${{ runner.os }}-docs-${{ github.sha }}
- restore-keys: |
- turbo-${{ runner.os }}-docs-
-
- # objectui#12957: every @objectstack/* package resolves to the build of the
- # commit `.objectstack-sha` pins, read from node_modules/.cache/objectstack, so
- # it must be there before pnpm resolves anything. Restored by the pin and built
- # only on a miss (scripts/objectstack-pin.mjs); the save is this job's last step.
- - name: Restore the pinned @objectstack build
- id: objectstack-pin
- if: steps.docs-changes.outputs.should_run == 'true'
- uses: actions/cache/restore@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
-
- - name: Build @objectstack at the pin (skips a restored build)
- id: objectstack-build
- if: steps.docs-changes.outputs.should_run == 'true'
- run: node scripts/objectstack-pin.mjs build
-
- - name: Install dependencies
- if: steps.docs-changes.outputs.should_run == 'true'
- run: pnpm install --frozen-lockfile
-
- # `TURBO_RUN_SUMMARY` makes turbo write its run summary to `.turbo/runs/`;
- # it changes nothing turbo runs or caches, and `Report what the turbo
- # cache replayed` below reads it (objectui#12276).
- - name: Build Site
- if: steps.docs-changes.outputs.should_run == 'true'
- env:
- TURBO_RUN_SUMMARY: 'true'
- run: pnpm turbo run build --filter='@object-ui/site'
-
- # REPORT-ONLY, by the 2026-09-06 ruling on objectui#7851, and it must stay
- # that way until somebody rules otherwise. The class it censuses has four
- # known members and three are OPEN (objectui#7440, #7444, #7838), so a
- # blocking gate here would go red on other people's cards the day it
- # landed. The script prints its findings and exits 0; it exits 1 only when
- # the INSTRUMENT is broken — a derivation that matched nothing, a missing
- # spec artifact, or a failed built-in control — because a census that runs,
- # goes green and looked at nothing is the counterfeit this whole check
- # exists to avoid (objectstack#4928, objectui#4690). ⛔ That is why there is
- # no `continue-on-error` here: it would swallow exactly the failure that
- # must be loud, and it is not what makes this step report-only.
- #
- # It sits in THIS job because its surface is `content/docs/**` and this job
- # is the one that already knows whether docs changed; it needs the install
- # (it reads the carriage map out of the built `@objectstack/spec`) and
- # nothing built beyond it. Consequence worth stating: it runs only when this
- # job's own filter fired, so a pull request that touches nothing the site
- # build consumes does not census either. objectui#8647 shrank that blind
- # spot rather than removing it — a `@objectstack/spec` bump moves
- # `pnpm-lock.yaml`, which is now in the filter, so the census does see the
- # bump that moves the carriage map; a change reaching the map by some other
- # route outside the filter is still reported by the next docs PR. A pull
- # request that edits only the gate is covered elsewhere and does not need
- # this step: `scripts/__tests__/check-doc-
- # expression-carriage.test.ts` runs the controls AND the real-tree census in
- # the ordinary vitest suite, which any PR touching `scripts/**` gets.
- - name: Docs expression-carriage census (report-only)
- if: steps.docs-changes.outputs.should_run == 'true'
- run: node scripts/check-doc-expression-carriage.mjs
-
- # ── What the cache replayed, and only `main` writes it (objectui#12276) ──
- # The `type-check` job's two bookkeeping steps (objectui#12222), for this
- # job's cache. The report reads the summary `Build Site` asked turbo for
- # and puts what the restored cache replayed, what re-ran and the restored
- # key on ONE `::notice` annotation, readable through the check-runs API
- # by anyone who cannot open this log. The prune deletes every entry this
- # run did not use, on `push` and `schedule` only -- the runs that save --
- # so `main`'s entry is exactly the current build outputs instead of
- # every build output any earlier run left behind; pull-request and queue
- # runs restore and never save (see the save step below).
- #
- # Both are bookkeeping after the last checking step, bounded and
- # `continue-on-error`. `timeout-minutes: 1` each is DERIVED FOR THIS
- # SITE: the script reads one run summary and lists one directory, and
- # measured 87 ms on a lint run's summary (objectui#12276); a minute is
- # several hundred times that, and with the save's 5 this job's
- # bookkeeping stays at 7 of its 15 -- inside half the ceiling, which is
- # the rule the save's own derivation below applies.
- - name: Report what the turbo cache replayed
- if: steps.docs-changes.outputs.should_run == 'true' && !cancelled()
- timeout-minutes: 1
- continue-on-error: true
- env:
- RESTORED_CACHE_KEY: ${{ steps.turbo-cache.outputs.cache-matched-key }}
- run: node scripts/report-turbo-cache.mjs
-
- - name: Prune the turbo cache to what this run used
- if: >-
- steps.docs-changes.outputs.should_run == 'true'
- && (github.event_name == 'push' || github.event_name == 'schedule')
- timeout-minutes: 1
- continue-on-error: true
- run: node scripts/report-turbo-cache.mjs --prune
-
- # The bookkeeping half of the split documented at the restore step above,
- # placed HERE — after `Build Site`, the step that records this job's
- # verdict — because that is exactly where the post phase it replaces
- # already ran. Nothing about which commits this job accepts or rejects
- # moves with it.
- #
- # `timeout-minutes: 5` is DERIVED FOR THIS SITE, and landing on the same
- # number PR #7047 chose is a result rather than a copy — objectui#7048
- # fences inheriting that 5, so here is this site's own arithmetic. This
- # cache is `.turbo/cache`, and this job's own save of it measured 5s on
- # 2026-09-02 (`Post Turbo Cache`, job 100094597253, a `merge_group` build
- # where this job's steps actually run — on a pull request that touches
- # nothing the site build consumes they all skip). 5 minutes is 60x
- # that, so it cannot fire on a working runner.
- # Ceiling check: 5 x 2 = 10 <= 15, so bookkeeping cannot crowd out the
- # checking steps — whose whole verdict path measured 4m26s on that job.
- #
- # `continue-on-error: true` is the other half, and without it the bound
- # would only trade a `cancelled` gate for a red one. A cache that failed
- # to upload costs the next run some time; it says nothing whatsoever
- # about the code under test, so it must not be allowed to speak for it.
- #
- # Behaviour preserved, spelled out so the equivalence is checkable:
- # - `cache-hit != 'true'` reproduces the combined action's own "exact
- # hit on the primary key ⇒ do not save" skip. Note this is the EXACT
- # hit only: a `restore-keys` prefix match leaves `cache-hit` false
- # and the save still runs, which is what the combined action did.
- # - the condition names no status function, so the implicit `success()`
- # still applies — matching the combined action's `post-if: success()`.
- # - same `path` and same `key` as the restore step above.
- # One deliberate change (objectui#12276): only `push` and `schedule`
- # save, after the prune step above, as in the `type-check` job.
- - name: Save Turbo Cache
- if: >-
- steps.docs-changes.outputs.should_run == 'true'
- && steps.turbo-cache.outputs.cache-hit != 'true'
- && (github.event_name == 'push' || github.event_name == 'schedule')
- uses: actions/cache/save@v6
- timeout-minutes: 5
- continue-on-error: true
- with:
- path: .turbo/cache
- key: turbo-${{ runner.os }}-docs-${{ github.sha }}
-
- # The pinned @objectstack build, saved only when this job built it (a cache
- # miss) and whatever the verdict above was -- it does not depend on it.
- # Bookkeeping: bounded and non-fatal (objectui#6577, objectui#7048). The bound
- # is not yet derived from a measured save; re-derive it from this step's runs.
- - name: Save the pinned @objectstack build
- if: ${{ !cancelled() && steps.docs-changes.outputs.should_run == 'true' && steps.objectstack-build.outcome == 'success' && steps.objectstack-pin.outputs.cache-hit != 'true' }}
- continue-on-error: true
- timeout-minutes: 2
- uses: actions/cache/save@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
diff --git a/ui/.github/workflows/cross-repo-issue-closer.yml b/ui/.github/workflows/cross-repo-issue-closer.yml
deleted file mode 100644
index 5d320524cfb..00000000000
--- a/ui/.github/workflows/cross-repo-issue-closer.yml
+++ /dev/null
@@ -1,618 +0,0 @@
-# GitHub's closing keywords (`Fixes #123`) only work WITHIN a repository. A PR
-# here that says `Fixes objectstack-ai/objectstack#4475` reads exactly like a
-# same-repo close to a human, merges, and leaves that issue open forever — with
-# no reference to the PR on the issue's own page either, so the next reader has
-# no way to find the fix.
-#
-# This is not hypothetical for this repository: defects are routinely FOUND in
-# objectstack (where verification runs) and FIXED here. During v17 verification
-# that happened twice in one day — #3150 fixed objectstack#4475 and #3163 fixed
-# objectstack#4478 — and both framework issues had to be closed by hand.
-#
-# This job closes the loop. It deliberately has TWO modes and BOTH are visible:
-#
-# token present -> close the foreign issue and comment with the PR link
-# token absent -> comment ON THIS PR naming what still needs closing by hand
-#
-# The second mode is the point. A workflow that quietly does nothing because a
-# secret was never provisioned is exactly the "declared but never enforced"
-# shape both repositories keep having to fix. Missing credentials must announce
-# themselves.
-#
-# ## This file is a PORT, and the four things it carries across (#5261)
-#
-# It landed here on 2026-08-09 (#3969) as a copy of the framework's file taken
-# before four rounds of hardening reached that copy. Each round removed one
-# SILENT exit, and this port brings all four across at once rather than
-# re-deriving them; the framework cards carry the full arguments.
-#
-# 1. The token-absent notice's DELIVERY is retried, never assumed
-# (objectstack#9575). Until then a transient answer from the comments
-# endpoint threw, github-script handed the throw to
-# `main().catch(handleError)` -> `core.setFailed`, and the run lost BOTH
-# the notice AND — because it sat after the `await` — the `core.warning`
-# that named what had been left open. So now: the transient class is
-# retried (declared in the step's `retries:` inputs, not hand-classified),
-# the work order is announced BEFORE delivery is attempted, and a refusal
-# that outlives the retries writes the notice into the run's job summary
-# and then FAILS the job.
-# 2. The per-target close loop reached the same rule one card later
-# (objectstack#9595). Its `catch` is unchanged and must stay so: it buys
-# ISOLATION, and one unreachable target taking the rest down with it would
-# be a worse failure than the one being fixed. What it did NOT buy is a
-# verdict. `core.warning` leaves the job green, so a refused close left the
-# foreign issue open, put nothing on the PR, and produced a conclusion
-# identical to the runs where there was nothing to do at all — while the
-# catch's own comment said "a failure here must not read as success".
-# Isolation and outcome are now separate: the loop records the keys it
-# could not close, runs to the end, and the verdict is passed after it.
-# 3. The already-closed target was skipped WHOLE (objectstack#9643), and the
-# skip covered two calls of which only one is redundant: re-closing a
-# closed issue is a no-op worth avoiding, but the BACKLINK is the half this
-# file's own first paragraph calls the defect — "no way to find the fix" —
-# and a wholesale skip dropped it and reported green.
-# 4. A closing keyword aimed at a foreign PULL REQUEST used to be honoured
-# (objectstack#9711). Every pull request is also an issue to
-# `GET /repos/{owner}/{repo}/issues/{N}`, so `owner/repo#N` naming a PR
-# reached the loop like anything else and would have been commented on and
-# CLOSED. It is now refused, out loud, BEFORE the already-closed branch.
-#
-# Item 3 is the one this repository was actually living with, and that is
-# measured rather than assumed. Over ALL 3425 merged pull requests in this
-# repository (#1..#5288, the closed-PR listing exhausted at 37 pages), 60 carry
-# a qualified foreign closing keyword — 61 occurrences over 59 distinct targets,
-# where the framework's own equivalent measurement found ZERO in its window. Of
-# those 60, fifteen merged after this file landed on 2026-08-09, and in every
-# one of them the framework issue was closed BY A SEAT one to two seconds after
-# the merge, before this workflow's run had even been created. So the loop hit
-# `state === 'closed'` fifteen times out of fifteen, skipped each target whole,
-# left no backlink on any of them, and reported success — which is what all 941
-# runs of this workflow have reported. Not one of those fifteen framework issues
-# carries this workflow's backlink comment today.
-#
-# Item 4, by the same measurement, has never yet had an occasion here: of the 59
-# distinct targets, 57 could be resolved (52 directly, plus five through the
-# repository renames `objectstack-ai/framework` -> `objectstack-ai/objectstack`
-# and `objectql/objectui` -> `objectstack-ai/objectui`, each corroborated by the
-# naming PR's own title) and ALL 57 are issues, not pull requests. Two —
-# `objectstack-ai/cloud#667` and `#654`, from #2045 and #2040 — could not be
-# read. The guard is therefore insurance rather than a repair, but insurance on
-# a LIVE path: this repository writes ~1.5 qualified foreign closing keywords a
-# day, so a mistyped one is an ordinary authoring slip here, not a hypothetical.
-#
-# ## Why a refusal FAILS the job here
-#
-# Failing is deliberate on both exits, and the affordability of it is a property
-# of this job in THIS repository, measured rather than inherited:
-#
-# - its conclusion is in no required set — the `main` ruleset (11776024)
-# carries `deletion`, `non_fast_forward`, `merge_queue` and `pull_request`
-# rules and NO `required_status_checks` rule at all, so no context is
-# required on this repository today;
-# - it runs only AFTER the merge (`pull_request_target: [closed]` plus
-# `merged == true`), so its conclusion gates nothing that has not already
-# happened;
-# - no `workflow_run:` listener exists in this repository at all, so a red
-# starts no fan-out.
-#
-# A red therefore costs one X on an already-merged PR. A green that delivered
-# nothing costs the foreign issues this workflow exists to stop losing — and
-# looks exactly like the several hundred green runs where there was simply
-# nothing to report. The fifteen measured above are precisely that failure
-# already happening in the quiet direction.
-name: Cross-repo Issue Closer
-
-# `pull_request_target` (not `pull_request`) because the job needs repository
-# secrets, which `pull_request` withholds from fork-originated runs. The usual
-# hazard of `pull_request_target` — running untrusted PR code with write
-# credentials — does not apply: this job never checks out the head ref and
-# never executes anything from the PR. It reads the PR body and calls the
-# issues API, nothing else.
-on:
- pull_request_target:
- types: [closed]
-
-permissions:
- contents: read
- pull-requests: write
-
-jobs:
- close-foreign-issues:
- name: Close issues referenced in other repositories
- if: github.event.pull_request.merged == true
- runs-on: ubuntu-latest
-
- # ── The job ceiling, DERIVED FOR THIS JOB (objectui#7956) ────────────
- # Before this line the only backstop was GitHub's 360-minute default.
- #
- # ⚠️ The stakes here are NOT objectui#7270's. That card's argument leaned on
- # two of its three jobs producing a REQUIRED context on a shared serial
- # queue. Re-measured on `GET /repos/objectstack-ai/objectui/rules/branches/
- # main` at 2026-09-06T17:20Z, the required checks are `Lint`, `Type Check`,
- # `Build & E2E`, `Test (shard 1/4)` through `(4/4)`, `Build Docs` and
- # `Changeset Declaration` — this job produces none of them, and it runs on
- # `pull_request_target: closed` where it blocks nothing. What is left is the
- # generic exposure: a wedged job holds a runner for six hours and then
- # reports `cancelled`.
- #
- # ⛔ NOT inherited from `ci.yml`'s 10/15/20/30/40, nor from the ceilings
- # objectui#7270 derived — objectui#7048 fences exactly that. The arithmetic
- # below is this job's own.
- #
- # - Population: the last 300 successful runs of this workflow, window
- # 2026-09-03T07:35Z .. 2026-09-06T17:19Z, n=300
- # `Close issues referenced in other repositories` jobs. Per-job wall
- # clock from the Actions jobs endpoint (`completed_at` minus
- # `started_at`), never the run's total.
- # - min 2s / median 4s / p95 5s / max 12s.
- # - Single event (`pull_request_target`, n=300), so there is no by-event
- # split to report.
- # - max/p95 = 2.4 — a mild tail at the high end, and one run in 300 at 12s
- # against a 4s median. Recorded rather than smoothed, because the max is
- # what the ceiling has to clear.
- # - ⚠️ One term is DECLARED and does not appear in the sample: the
- # github-script step below sets `retries: 3`, so a target answering
- # transiently is asked up to four times with octokit's backoff between
- # attempts, and the sample contains no run that spent that budget. The
- # additive 15 minutes below is where it is absorbed — the same reasoning
- # `changeset-release.yml::release` applies to its declared wait cap,
- # except that there the declared bound exceeded the multiplier and had
- # to be added explicitly, and here it does not come close.
- #
- # Ceiling = the smallest round number that is both >= 3x max (0.6min) and
- # >= max + 15min (15.2min) => 20. That is ~100x the slowest run ever observed
- # here, so it cannot fire on a healthy-but-slow runner; a wedge dies in 20
- # minutes instead of 6 hours.
- #
- # ⚠️ Four of the six jobs objectui#7956 bounded land on 20 by this same
- # arithmetic. That is NOT one number copied across them, which precondition
- # ② of that card's triage forbids: for any job whose slowest run is under
- # 7.5 minutes the additive term dominates the multiplicative one, so the
- # rule puts every sub-minute job on the same rung. Each of the four carries
- # its own sample and its own derivation beside its own key.
- #
- # ⛔ Raising this toward 360 is the ruled-out non-fix (objectui#6577,
- # objectui#7048): the hang just runs longer and the gate still reports
- # `cancelled`.
- timeout-minutes: 20
- steps:
- - name: Close (or report) cross-repo closing keywords
- uses: actions/github-script@v9
- env:
- # A fine-grained PAT or GitHub App token with `issues: write` on the
- # sibling repositories. `GITHUB_TOKEN` cannot do this — it is scoped
- # to the repository running the workflow, which is the whole problem.
- CROSS_REPO_TOKEN: ${{ secrets.CROSS_REPO_ISSUE_TOKEN }}
- with:
- # Hand the cross-repo token to the action itself, so `github` IS the
- # cross-repo client. `require('@actions/github')` does NOT work here:
- # github-script bundles its dependencies and the module is not
- # resolvable from the script scope (`MODULE_NOT_FOUND`, seen on this
- # workflow's first successful-parse run). Falling back to
- # GITHUB_TOKEN keeps the report path able to comment on this PR.
- github-token: ${{ secrets.CROSS_REPO_ISSUE_TOKEN || secrets.GITHUB_TOKEN }}
- # The transient-retry policy, DECLARED rather than hand-written
- # (objectstack#9575/#9576): github-script has accepted these two inputs
- # since v6 and every run of this job already echoes their defaults into
- # its own log — `retries: 0`,
- # `retry-exempt-status-codes: 400,401,403,404,422`. They drive octokit's
- # retry plugin, which re-issues any request whose status is not exempt,
- # plus network-level failures. Nothing is swallowed: a spent retry still
- # throws. This widens how many times a request is ASKED and no verdict
- # anywhere.
- #
- # It is a step input, so it covers the per-target loop below as well as
- # the notice. The loop keeps its own `try`/`catch` unchanged — retries
- # change how often a target is asked, never whether one unreachable
- # target may take the rest down with it.
- #
- # 403 is dropped from the action's default exempt list on purpose:
- # GitHub answers a SECONDARY rate limit with 403 as well as with 429, so
- # the status alone cannot separate weather from a permission denial. The
- # price is that a genuine denial — a cross-repo PAT without
- # `issues: write` on a sibling repo — now takes four attempts per target
- # to fail instead of one. It still fails, and the loop still names the
- # target it failed on.
- #
- # 400/401/404/422 stay exempt: a malformed request, a wrong target, or a
- # body past GitHub's 65536-character comment limit is this repo's own
- # bug. It is answered correctly on the first try and asking again only
- # spends runner minutes.
- retries: 3
- retry-exempt-status-codes: 400,401,404,422
- script: |
- const body = context.payload.pull_request.body || '';
- const prUrl = context.payload.pull_request.html_url;
- const thisRepo = `${context.repo.owner}/${context.repo.repo}`;
-
- // ONE vocabulary for a refusal, used by both exits below: `HTTP 503`
- // or `ECONNRESET` is what lets a reader separate platform weather
- // from a 403/404 that means the credential is wrong. Hoisted rather
- // than copied per call site — the framework copy reached the same
- // shape (objectstack#9576), and a second hand-written copy is the
- // thing that note is about.
- const describe = (error) => {
- const kind = typeof error?.status === 'number'
- ? `HTTP ${error.status}`
- : (error?.code || 'error');
- // Octokit messages usually end in a full stop; ours supplies its own.
- return `${kind}: ${String(error?.message || '').replace(/\s*\.\s*$/, '')}`;
- };
-
- // GitHub's own keyword set, restricted to the qualified
- // `owner/repo#N` form — the bare `#N` form already works natively
- // and must not be touched here.
- const KEYWORDS = 'close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved';
- const pattern = new RegExp(
- `\\b(?:${KEYWORDS})\\s+([\\w.-]+)\\/([\\w.-]+)#(\\d+)\\b`,
- 'gi',
- );
-
- // Report credential state on EVERY run, before any early return.
- // Otherwise a repository with the secret and one without look
- // identical until a cross-repo reference happens to show up —
- // which can be days — and "is it configured?" stays unanswerable.
- // Presence only; the value is never read into the log.
- const token = process.env.CROSS_REPO_TOKEN;
- core.info(
- `CROSS_REPO_ISSUE_TOKEN: ${token ? 'configured' : 'ABSENT — cross-repo closes will be reported, not performed'}`,
- );
-
- const targets = new Map();
- for (const [, owner, repo, number] of body.matchAll(pattern)) {
- const key = `${owner}/${repo}#${number}`;
- // Skip same-repo references: GitHub already closed those, and
- // closing them again would be a no-op comment on every merge.
- if (`${owner}/${repo}`.toLowerCase() === thisRepo.toLowerCase()) continue;
- targets.set(key, { owner, repo, number: Number(number) });
- }
-
- if (targets.size === 0) {
- core.info('No cross-repository closing keywords in this PR body.');
- return;
- }
- core.info(`Cross-repo targets: ${[...targets.keys()].join(', ')}`);
-
- if (!token) {
- // Degrade VISIBLY. Someone has to close these by hand, and this
- // comment is the only thing that will tell them so.
- const list = [...targets.keys()].map((k) => `- \`${k}\``).join('\n');
- const keys = [...targets.keys()].join(', ');
-
- // The work order is announced BEFORE it is delivered, and it names
- // the TARGETS rather than only how many there are
- // (objectstack#9575). This
- // annotation used to sit after the `await` below, which is the same
- // as not existing on the one run where it matters: a throw from the
- // post skipped it, so a refused notice lost the list AND the count.
- // It costs one line of log when delivery succeeds.
- core.warning(
- `CROSS_REPO_ISSUE_TOKEN is not configured — ${targets.size} issue(s) must be `
- + `closed BY HAND: ${keys}.`,
- { title: 'Cross-repo issues left open' },
- );
-
- const notice =
- `### ⚠️ 跨仓库 issue 未被自动关闭\n\n` +
- `本 PR 的正文声明了跨仓库关闭关键字,但 GitHub 的关闭关键字**只在同仓库内生效**,` +
- `因此以下 issue 仍处于 open 状态,需要**手工关闭**:\n\n${list}\n\n` +
- `自动关闭需要仓库 secret \`CROSS_REPO_ISSUE_TOKEN\`(对目标仓库具备 \`issues: write\` 的` +
- ` fine-grained PAT 或 GitHub App token)。\`GITHUB_TOKEN\` 只对当前仓库有写权限,无法胜任。\n\n` +
- `配置该 secret 后本条提示会自动消失,改为直接关闭目标 issue。\n\n` +
- `---\n_Generated by [Claude Code](https://claude.ai/code)_`;
-
- // This comment IS the deliverable, not a courtesy about one — see
- // the header. The step's `retries:` have already absorbed a blip by
- // the time anything reaches the catch, so what is left there is a
- // refusal that outlived them.
- try {
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: context.payload.pull_request.number,
- body: notice,
- });
- core.info(
- `Reported ${targets.size} unclosed cross-repo issue(s) on this pull request.`,
- );
- } catch (error) {
- const reason = describe(error);
- try {
- await core.summary.addRaw([
- '## ⚠️ 跨仓库关闭提示没能发到 PR 上',
- '',
- `\`issues.createComment\` 最终被拒绝:\`${reason}\`。`,
- '',
- `- 下面就是本次运行算出的完整提示 —— PR #${context.payload.pull_request.number} 上没有它。`,
- '- 可以直接复制到 PR 上;也可以在 API 恢复后 re-run 本 job。',
- '- 真正要做的事情是上面列出的那几个 issue:它们仍是 open,需要手工关闭。',
- '- 根治是配置仓库 secret `CROSS_REPO_ISSUE_TOKEN`,之后本分支不再运行。',
- '',
- '---',
- '',
- notice,
- '',
- ].join('\n')).write();
- } catch (summaryError) {
- // The summary is the richer channel, the annotation the reliable
- // one. Losing the richer one must not restore the silence this
- // whole branch exists to break.
- core.info(`Could not write the job summary: ${summaryError.message}`);
- }
- core.setFailed(
- `${targets.size} cross-repo issue(s) were left open by this merge and the notice `
- + `saying so could NOT be posted on PR #${context.payload.pull_request.number} `
- + `(${reason}). Close these by hand: ${keys}. The full notice is reproduced in `
- + `this run's job summary; re-run this job to retry delivery.`,
- );
- }
- return;
- }
-
- // ISOLATION and OUTCOME are separable, and the loop owns only the
- // first (objectstack#9595). The `catch` below must keep swallowing
- // — one
- // unreachable target must not take the rest down — so the verdict
- // is passed AFTER the loop, over the keys it collected. Until this
- // split existed the two were fused into `core.warning` alone: a
- // refused close left the foreign issue open, put no notice on the
- // PR, and handed the run the same green conclusion as the several
- // hundred runs that had nothing to do at all. The catch's own comment
- // already stated the requirement; only the code was missing.
- const failures = [];
-
- // Targets that are not issues AT ALL. Kept apart from `failures`
- // above because the two need opposite remedies: a failure is an API
- // refusal a re-run can clear, this one is a defect in the merged
- // PR's own body that no re-run will ever change. Fusing them would
- // send the reader off to re-run a job that is going to refuse again.
- const malformed = [];
-
- // The backlink this PR leaves, identified so a SECOND run can see
- // it. The marker is scoped to one pull request and is therefore
- // STABLE across runs — one PR leaves one backlink, however many
- // times the job runs. That is deliberate, and it decides the
- // degradation direction below: a marker carrying a RUN id makes a
- // duplicate self-evidently one comment per run, so posting blind is
- // safe for it; a STABLE marker strands a permanent second copy
- // instead. The framework copy weighs both classes side by side
- // (objectstack#9423/#9424). This one is in the second.
- const backlinkMarker =
- ``;
- const backlink = (lead) =>
- `${lead}\n\n` +
- `(跨仓库的关闭关键字不会自动生效,本条由 \`cross-repo-issue-closer\` 工作流代为收口。)\n\n` +
- `---\n_Generated by [Claude Code](https://claude.ai/code)_\n\n${backlinkMarker}`;
-
- for (const [key, t] of targets) {
- // What a refusal from here costs, for the verdict after the loop.
- // Until the issue has been read there is no way to know it is
- // already closed, so a refusal costs both halves.
- let lost = 'close and backlink';
- try {
- const { data: issue } = await github.rest.issues.get({
- owner: t.owner, repo: t.repo, issue_number: t.number,
- });
-
- // A pull request is ALSO an issue to this endpoint. Every PR
- // answers `GET /repos/{owner}/{repo}/issues/{N}` with `state`,
- // `state_reason` and the rest, plus a `pull_request` key that
- // nothing here used to read (objectstack#9711) — the key is
- // ABSENT on a
- // real issue, present as an object on a PR, so its truthiness
- // is the whole test. The target regex takes any `owner/repo#N`
- // and PR and issue numbers share one sequence, so a body saying
- // `Fixes owner/repo#4500` where 4500 is a pull request would
- // have made this loop comment on that PR and CLOSE it.
- //
- // GitHub's own closing-keyword parser — the behaviour this
- // workflow exists to carry across repository boundaries — never
- // closes a pull request; `Fixes #N` aimed at a PR leaves a
- // reference and nothing else. So the keyword is a MALFORMED
- // instruction, and it is refused OUT LOUD rather than skipped:
- // this file's ten exit paths were arrived at by fixing one
- // quiet one after another, and a quiet `continue` would have
- // been the eleventh. The refusal is also the conservative half
- // of an asymmetry — closing a pull request drops its
- // merge-queue membership and any armed auto-merge in the same
- // step, and neither comes back by itself.
- //
- // Placed BEFORE the `state === 'closed'` branch on purpose: a
- // merged pull request reads `state: 'closed'` with
- // `state_reason: null` from this very endpoint (measured on
- // objectstack-ai/objectstack#9143), which that branch reads as
- // "no objection recorded" and answers with a backlink comment.
- // A guard sitting after it would still write on somebody else's
- // pull request.
- if (issue.pull_request) {
- malformed.push({ key, url: issue.pull_request.html_url });
- core.warning(
- `${key} is a PULL REQUEST, not an issue — ${prUrl} declares it FIXED, but GitHub's own `
- + `closing-keyword parser never closes a pull request, so this workflow does not either. `
- + `Nothing was commented on and nothing was closed. Re-running cannot fix a keyword that `
- + `can never fire: point the reference at the issue it meant.`,
- { title: 'Cross-repo closing keyword names a pull request' },
- );
- continue;
- }
-
- // "Already closed" is NOT one situation, and the three causes
- // that reach here do not deserve the same treatment (objectstack#9643).
- // Until this split existed all three were skipped whole, which
- // dropped the backlink — the half of the defect the header
- // names — on every one of them, and reported green.
- if (issue.state === 'closed') {
- // (a) Closed as not-planned or as a duplicate. A comment
- // saying this PR fixed it would CONTRADICT the triage
- // decision on the issue, so nothing is posted. Two people
- // have said opposite things and only a human can settle
- // it, hence an annotation rather than a silent skip.
- // `state_reason` is the only cause the API states
- // outright; it is nullable (a real closed issue answers
- // `null` here), and null is deliberately read as "no
- // objection recorded", i.e. as `completed`.
- if (issue.state_reason === 'not_planned' || issue.state_reason === 'duplicate') {
- core.warning(
- `${key} is closed as \`${issue.state_reason}\`, but this PR's body declares it FIXED. `
- + `No backlink was left: a comment saying ${prUrl} closed it would contradict that `
- + `triage decision. Someone has to decide which of the two is right.`,
- { title: 'Cross-repo close contradicts the issue triage' },
- );
- continue;
- }
-
- // (b) Closed by an EARLIER run of this job — a re-run, which
- // this workflow's own job summary invites. The marker is
- // the only evidence that separates it from (c): the API
- // cannot, because `closed_by` is a login and every seat
- // here shares one identity, so "the workflow did it" and
- // "a human did it" read the same.
- let alreadyLinked = false;
- try {
- // One page is the bound. Overflowing it costs a DUPLICATE
- // backlink, never a lost one, and the stable marker makes
- // the pair self-evident.
- const existing = await github.rest.issues.listComments({
- owner: t.owner, repo: t.repo, issue_number: t.number, per_page: 100,
- });
- alreadyLinked = existing.data.some((c) => (c.body ?? '').includes(backlinkMarker));
- } catch (error) {
- // AT-MOST-ONCE, stated: the marker is stable, so posting
- // without knowing strands a second permanent backlink on
- // somebody ELSE's repo, which nobody here can tidy up. A
- // miss costs a convenience link on an already-closed issue
- // and a re-run recovers it. Loud either way — this is a
- // skip with a reason, not a silent one.
- core.warning(
- `${key} is already closed, but its comments could not be read `
- + `(${describe(error)}), so this run could not tell whether ${prUrl} is already `
- + `linked there. No backlink was posted — re-run this job to leave it.`,
- { title: 'Cross-repo backlink skipped, not confirmed' },
- );
- continue;
- }
- if (alreadyLinked) {
- core.info(`${key} is already closed and already carries this PR's backlink — nothing to do.`);
- continue;
- }
-
- // (c) Closed by a human before this PR merged — the ordinary
- // sequence the header is about (v17 verification: #3150
- // fixed objectstack#4475, #3163 fixed objectstack#4478).
- // The close is genuinely redundant; the backlink is the
- // whole remaining value, so leave it and touch nothing
- // else.
- lost = 'backlink';
- await github.rest.issues.createComment({
- owner: t.owner, repo: t.repo, issue_number: t.number,
- body: backlink(
- `本 issue 在本次合并之前就已经是 closed 状态,这里只补上修复它的 PR:` +
- `${thisRepo} 的 ${prUrl}。本工作流没有改动它的状态。`,
- ),
- });
- core.info(`${key} was already closed — left the backlink to ${prUrl}, did not re-close it.`);
- continue;
- }
-
- await github.rest.issues.createComment({
- owner: t.owner, repo: t.repo, issue_number: t.number,
- body: backlink(`已由 ${thisRepo} 的 ${prUrl} 修复并合并。`),
- });
- await github.rest.issues.update({
- owner: t.owner, repo: t.repo, issue_number: t.number,
- state: 'closed', state_reason: 'completed',
- });
- core.info(`Closed ${key}.`);
- } catch (error) {
- // One unreachable target must not swallow the rest, and a
- // failure here must not read as success. This half buys the
- // first: the loop continues. The failure is RECORDED instead of
- // dropped, and the second half is passed below.
- const reason = describe(error);
- failures.push({ key, reason, lost });
- core.warning(`Could not finish ${key} — ${lost} not delivered: ${reason}`, {
- title: 'Cross-repo issue left unfinished',
- });
- }
- }
-
- if (failures.length === 0 && malformed.length === 0) {
- core.info(`All ${targets.size} cross-repo target(s) handled: closed, or already closed and linked.`);
- return;
- }
-
- // A spent failure IS the report. Announce it in both channels the
- // notice branch uses, for the same reasons: the summary carries the
- // full list a human needs, `setFailed` carries the conclusion that
- // makes anyone open the run at all.
- //
- // Both classes are red, and each says what to do about ITSELF: a
- // refusal is retried, a malformed reference is rewritten. Green
- // with an annotation was the alternative for the malformed class,
- // and this repository's own history rejects it — 941 runs of this
- // job, the last 100 of them green without exception, including the
- // fifteen measured in the header that delivered nothing at all. A
- // green run of this job has never been opened by anybody here. An
- // annotation on a
- // green post-merge run is the silent path with extra steps, and a
- // declared close that can never happen is exactly what objectstack#9595 made
- // red for.
- const failedKeys = failures.map((f) => f.key).join(', ');
- const failedList = failures.map((f) => `- \`${f.key}\` —— ${f.lost} 未完成:${f.reason}`).join('\n');
- const malformedKeys = malformed.map((m) => m.key).join(', ');
- const malformedList = malformed.map((m) => `- \`${m.key}\` —— 这个编号是一个 pull request:${m.url}`).join('\n');
- const summaryLines = [];
- if (failures.length > 0) {
- summaryLines.push(
- '## ⚠️ 跨仓库 issue 没能收口',
- '',
- `本次合并声明了 ${targets.size} 个跨仓库关闭目标,其中 ${failures.length} 个没有完成:`,
- '',
- failedList,
- '',
- `- 修复它们的是 ${prUrl} —— 需要手工收口:还是 open 的要关掉,已经关掉的要把这条链接留在上面。`,
- '- 原因排除后可以直接 re-run 本 job:已经关闭的目标不会被重复关闭,已经留下过本 PR 反链的目标也不会被重复评论。',
- '- 401/404 通常意味着 `CROSS_REPO_ISSUE_TOKEN` 对目标仓库没有 `issues: write`,而不是目标不存在 —— GitHub 对无权访问的仓库回 404。',
- '',
- );
- }
- if (malformed.length > 0) {
- summaryLines.push(
- '## ⛔ 关闭关键字指向的是 pull request,不是 issue',
- '',
- `本次合并声明的 ${targets.size} 个跨仓库关闭目标里,有 ${malformed.length} 个的编号是 pull request:`,
- '',
- malformedList,
- '',
- '- 它们没有被评论,也没有被关闭。GitHub 自己的关闭关键字解析器从不关闭 pull request,本工作流也不会 —— 关掉一个 PR 会在同一步里丢掉它的 merge queue 成员资格和已经武装的 auto-merge,两者都不会自己回来。',
- `- 这不是 re-run 能解决的故障:写错的是 ${prUrl} 的正文。把引用改成它真正想关的那个 issue 编号,或者手工关掉那个 issue。`,
- '',
- );
- }
- try {
- await core.summary.addRaw(summaryLines.join('\n')).write();
- } catch (summaryError) {
- // Same asymmetry as the notice branch: the summary is the richer
- // channel, the conclusion the reliable one. Losing the richer one
- // must not restore the silence.
- core.info(`Could not write the job summary: ${summaryError.message}`);
- }
- const verdict = [];
- if (failures.length > 0) {
- verdict.push(
- `${failures.length} of ${targets.size} cross-repo target(s) were NOT finished by this merge: `
- + `${failedKeys}. Finish them by hand (fixed by ${prUrl}) — an open one needs closing, an `
- + `already-closed one needs this PR's link on it — or re-run this job once the cause is `
- + `cleared. The full list, with what each one is missing, is in this run's job summary.`,
- );
- }
- if (malformed.length > 0) {
- verdict.push(
- `${malformed.length} of ${targets.size} cross-repo closing keyword(s) in this merged pull `
- + `request name a PULL REQUEST rather than an issue: ${malformedKeys}. GitHub's own keyword `
- + `parser never closes a pull request and this workflow does not either, so nothing was `
- + `commented on and nothing was closed. Re-running this job will refuse them again — what `
- + `has to change is the reference in ${prUrl}'s body.`,
- );
- }
- core.setFailed(verdict.join(' '));
diff --git a/ui/.github/workflows/dependabot-auto-merge.yml b/ui/.github/workflows/dependabot-auto-merge.yml
deleted file mode 100644
index 9ca39926472..00000000000
--- a/ui/.github/workflows/dependabot-auto-merge.yml
+++ /dev/null
@@ -1,276 +0,0 @@
-name: Dependabot Auto-merge
-
-on:
- pull_request:
- branches: [main, develop]
-
-# ── Off until the objectstack cutover (objectui#13497) ─────────────────────────
-#
-# The job below carries `if: false`. Its last step enqueues with `gh pr merge
-# --auto` under GITHUB_TOKEN, and GitHub starts no workflow run for an event that
-# GITHUB_TOKEN caused. So the merge group it creates gets no `merge_group` runs,
-# the required checks never report on it, and the entry holds the head of the
-# merge queue until the queue's check timeout removes it, with every pull request
-# behind it waiting the whole time. Each Dependabot force-push re-ran this job and
-# re-enqueued, so the stall repeated. The measurements are on objectui#13497.
-#
-# This repository is archived when objectstack-ai/objectstack#25677 moves objectui
-# into objectstack, so the ruling on objectui#13497 took the cheaper fix: stop
-# enqueuing Dependabot pull requests from Actions. ⛔ No App or PAT secret was
-# added for an enqueue that runs, the queue timeout was not shortened, and no
-# required check was dropped.
-#
-# The whole job is off, not only its enqueue step: without the enqueue it would
-# still hold a runner for every Dependabot rebase in the cutover window, only to
-# approve a pull request that nothing enqueues. Everything else here is left as it
-# was. Until the cutover, nothing approves, enqueues or comments on a Dependabot
-# pull request; one lands only if someone enqueues it by hand once its checks are
-# green. To turn the job back on, give it back the condition `if: false` replaced,
-# `github.actor == 'dependabot[bot]'`, and fix the GITHUB_TOKEN enqueue first, or
-# the stall returns with it.
-
-# ── Why this workflow no longer enables auto-merge unconditionally (#4973) ────
-#
-# It used to run, for every semver-patch/minor bump and with nothing else asked:
-#
-# gh pr merge --auto --squash "$PR_URL"
-#
-# `--auto` lands the merge as soon as GitHub thinks the pull request is
-# mergeable, i.e. as soon as the BRANCH-PROTECTION required set is satisfied —
-# not when the checks this repository runs are green. Those are different sets,
-# and on 2026-08-17 the difference put a red commit on `main`:
-#
-# 08:13:07Z #4959's four `Test (shard N/4)` jobs start
-# 08:13:36Z github-actions[bot] merges #4959 into `main`
-# 08:21:01Z shard 3/4 -> failure (5m25s AFTER the merge)
-# 08:21:56Z shard 1/4 -> failure (8m20s AFTER the merge)
-# 08:22:33Z Type Check reports (8m57s AFTER the merge)
-#
-# Nine of the nineteen check runs on that head SHA were still in flight at the
-# moment of the merge. The shard matrix is the slowest job in the repo by
-# construction (it exists to cut a ~9 minute wall clock — see `ci.yml`), so it
-# is the check `--auto` systematically outruns; #4968 then had to repair `main`
-# for every parallel agent. The failure mode is not specific to lockfile ranges:
-# any red on a slow job could ride the same channel. Same family as #3523 (an
-# empty merge-queue required set let #3503/#3510/#3516 land with `Type Check` at
-# conclusion=failure) and #3243.
-#
-# So the wait is now EXPLICIT and this workflow owns it: `scripts/dependabot-
-# merge-gate.mjs` polls the Checks API for this exact head SHA until every
-# context it declares has reported `success`, and only then are the two
-# mutations — approve, enqueue — allowed to run. A context that is missing,
-# still running at the deadline, or non-`success` is not green; nothing merges,
-# the job goes red and a comment says which context refused. The declared set,
-# the reasons behind each bucket and the partition test that keeps it honest all
-# live in that script and in `scripts/__tests__/dependabot-merge-gate.test.ts`,
-# which replays the real #4959 timeline and asserts this gate would have stopped
-# it.
-#
-# Two things this deliberately does NOT do:
-#
-# * It does not ask GitHub which checks are required. That set is a
-# repository-SETTINGS surface nothing here can CHANGE
-# (`content/docs/guide/ci-cd-pipeline.md`, "Merge Queue", step 3) — the
-# protection endpoint answers 403 to this job's token and only a maintainer
-# edits the ruleset. Its membership can be READ, and the repository reads it
-# elsewhere on a schedule: `scripts/check-required-check-set.mjs`, wired as
-# `pnpm check:required-check-set` and run by
-# `.github/workflows/required-check-set-patrol.yml`. This gate still must not
-# delegate to that set — it is narrower than the wait below and a maintainer
-# can shrink it without any diff — so the wait is declared in the script and
-# tested there. ⛔ Do not restate the membership in this comment: the
-# sentence replaced here did, and went stale when the ruleset changed under
-# it (objectui#9502).
-# * It does not replace `--auto` with a direct merge. `main` is behind an
-# enforced merge queue: a direct merge is rejected with 405 `Changes must be
-# made through the merge queue` (measured in #3243, recorded in AGENTS.md
-# §9). Enabling auto-merge IS the enqueue action here. What changes is that
-# it happens only after the full check set is green on this SHA, instead of
-# 29 seconds after the shards started.
-permissions:
- contents: write
- pull-requests: write
- # The gate reads check runs for the pull request's head SHA. With an explicit
- # `permissions:` block every scope not named is `none`, so without this the
- # gate would 403 — and it fails closed: a throw exits non-zero, the job is red
- # and nothing is merged.
- checks: read
-
-# A second push to a Dependabot branch supersedes the first gate run. Without
-# this, two runs would sit in their poll loops for the same pull request and the
-# older one could enqueue a SHA that is no longer the head. Cancellation is the
-# safe direction: a cancelled gate merges nothing.
-concurrency:
- group: dependabot-auto-merge-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
-
-jobs:
- dependabot:
- runs-on: ubuntu-latest
- # Off until the objectstack cutover: see the header (objectui#13497).
- if: false
- # The gate waits for the slowest check in the repository. Measured on #4959
- # the last required context reported 9m26s after the jobs started, so the
- # script's own deadline is 40 minutes (`GATE_TIMEOUT_SECONDS`) and the job is
- # given a little more, so the deadline is always reached by the script — it
- # renders a report and comments — rather than by the runner killing the job
- # with nothing to read.
- timeout-minutes: 50
- steps:
- - name: Checkout code
- uses: actions/checkout@v7
- with:
- submodules: true
-
- # No pnpm setup here, deliberately (objectui#6392).
- #
- # This job ran `corepack enable` + `pnpm --version` until 2026-08-25,
- # as a leftover of the lockfile merge driver removed in objectui#6389
- # (see the "No lockfile merge driver here" note below) -- that driver
- # step was the only thing in this job that ever needed pnpm. Once it
- # was gone, enabling corepack and printing a pnpm version was a package
- # manager set up for zero consumers: the Setup Node.js step right below
- # already explains that this job never runs `pnpm install`, and nothing
- # else here shells out to `pnpm`.
- #
- # Considered and rejected: keeping `pnpm --version` alone as a "proves
- # corepack activated" fail-fast, so a future pnpm dependency would fail
- # clearly instead of confusingly. Rejected because that failure mode
- # has no occasion to matter until something in this job actually calls
- # pnpm, and nothing does today -- an unexplained two-line "just in
- # case" setup with no consumer is exactly the residue this issue is
- # about, and keeping half of it recreates the same shape. If a future
- # step here grows a real pnpm dependency, add `corepack enable` back
- # next to that step, where a reader can see what it's for.
- - name: Setup Node.js
- uses: actions/setup-node@v7
- with:
- node-version: '22.x'
- # No `cache: 'pnpm'` here: this job doesn't set up pnpm at all (see
- # the note above) and never runs `pnpm install`, so the pnpm store
- # doesn't exist and setup-node's post-job cache save would fail
- # with "Path Validation Error", marking the whole job as failed.
-
- # No lockfile merge driver here, deliberately (objectui#6369).
- #
- # This job configured one until 2026-08-25. A merge driver is invoked by
- # git only when git itself has to MERGE the attributed path, on the
- # runner. The only merge this job performs is the last step's `gh pr
- # merge --auto --squash`, and GitHub executes that SERVER-SIDE: the merge
- # commit is produced by GitHub's own machinery in the merge queue, so the
- # runner's local git config is never consulted and cannot participate.
- # The driver therefore had no occasion to fire.
- #
- # Swept before removing: this file contains no `git merge`, `rebase`,
- # `pull`, `cherry-pick`, `am`, `apply` or `revert` -- the `git config`
- # pair this comment replaces was the only `git` in it. `actions/checkout`
- # fetches a ref and checks it out; on a `pull_request` event that ref is
- # the merge commit GitHub has ALREADY computed, so checking it out merges
- # nothing locally (and `submodules: true` finds no `.gitmodules` here).
- # `scripts/dependabot-merge-gate.mjs` imports `node:fs` and one local
- # helper -- it shells out to nothing.
- #
- # Same no-occasion property as `changelog.yml` (objectui#6358), reached by
- # a different route: that job never merged at all, this one merges only
- # where local config cannot reach.
- #
- # Restoring it needs a real LOCAL merge in this job first. A server-side
- # merge is not one, and missing that distinction is what put the step here.
-
- - name: Dependabot metadata
- id: metadata
- uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0
- with:
- github-token: "${{ secrets.GITHUB_TOKEN }}"
-
- # Unchanged (#4973 does not touch the semver policy): patch and minor may
- # be merged automatically, major may not.
- - name: Check if auto-mergeable
- id: check-update
- run: |
- UPDATE_TYPE="${{ steps.metadata.outputs.update-type }}"
- if [[ "$UPDATE_TYPE" == "version-update:semver-patch" ]] || [[ "$UPDATE_TYPE" == "version-update:semver-minor" ]]; then
- echo "auto_merge=true" >> $GITHUB_OUTPUT
- else
- echo "auto_merge=false" >> $GITHUB_OUTPUT
- fi
-
- # The wait. Polls the Checks API for THIS head SHA; writes `gate=green|red`
- # plus a rendered report. It never mutates anything — the two steps below
- # are the only things that can write, and both are behind `gate == green`.
- #
- # Mechanism note (#4973 left the choice open): a poll loop in this job was
- # chosen over re-triggering on `check_suite: completed`. The `check_suite`
- # route wakes ~8 times per pull request instead of holding a runner, but it
- # arrives without the pull-request context `dependabot/fetch-metadata`
- # needs and without `github.actor == 'dependabot[bot]'`, so the semver
- # policy above would have to be re-derived on a different event — more
- # moving parts around the decision that just went wrong. The cost of this
- # shape is one mostly-idle runner for the ~10 minutes the shards take, per
- # Dependabot pull request; that is the price of not corrupting `main`.
- - name: Wait for the full check set on this head SHA
- id: gate
- if: steps.check-update.outputs.auto_merge == 'true'
- run: node scripts/dependabot-merge-gate.mjs
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- HEAD_SHA: ${{ github.event.pull_request.head.sha }}
- GATE_TIMEOUT_SECONDS: '2400'
- GATE_INTERVAL_SECONDS: '20'
- GATE_REPORT_FILE: dependabot-merge-gate.md
-
- # Requirement: a refusal must be visible on the pull request, not only in
- # a log nobody opens. The step summary is written by the script; this puts
- # the same text where a reviewer of the PR will see it.
- - name: Report the refusal on the pull request
- if: steps.gate.outputs.gate == 'red'
- run: gh pr comment "$PR_URL" --body-file dependabot-merge-gate.md
- env:
- PR_URL: ${{ github.event.pull_request.html_url }}
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- # Approval moved behind the gate too. An approval on a pull request whose
- # tests are red is a false signal to every human reading the PR list, and
- # where a ruleset counts approvals it is also half of the merge decision.
- - name: Approve PR
- if: steps.gate.outputs.gate == 'green'
- run: gh pr review --approve "$PR_URL"
- env:
- PR_URL: ${{ github.event.pull_request.html_url }}
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- # `--match-head-commit` pins the merge to the SHA the gate actually
- # judged: if Dependabot pushed a new commit while the gate was waiting,
- # this refuses instead of enqueueing an unverified head. If a future `gh`
- # ever drops the flag the step exits non-zero, so the failure direction is
- # "nothing merged, job red", not "merged unverified".
- - name: Enqueue for merge (auto-merge = enter the merge queue)
- if: steps.gate.outputs.gate == 'green'
- run: gh pr merge --auto --squash --match-head-commit "$GATED_SHA" "$PR_URL"
- env:
- PR_URL: ${{ github.event.pull_request.html_url }}
- GATED_SHA: ${{ steps.gate.outputs.gated_sha }}
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- # Make the refusal red. The gate script exits 0 on a DECIDED red so that
- # the comment above can run (a crash inside it exits non-zero on its own
- # and fails the job here regardless), so the job's own conclusion has to be
- # set explicitly — otherwise a refused merge would report as a green
- # `dependabot` check, which is the shape of silence this issue is about.
- - name: Fail the job when the gate refused
- if: steps.check-update.outputs.auto_merge == 'true' && steps.gate.outputs.gate != 'green'
- run: |
- echo "::error::Dependabot merge gate did not go green — see the job summary and the PR comment."
- exit 1
-
- - name: Comment on major updates
- if: steps.metadata.outputs.update-type == 'version-update:semver-major'
- uses: actions/github-script@v9
- with:
- script: |
- github.rest.issues.createComment({
- issue_number: context.issue.number,
- owner: context.repo.owner,
- repo: context.repo.repo,
- body: '⚠️ This is a **major version update**. Please review carefully before merging.'
- });
diff --git a/ui/.github/workflows/docs-deploy.yml b/ui/.github/workflows/docs-deploy.yml
deleted file mode 100644
index 1d92570823d..00000000000
--- a/ui/.github/workflows/docs-deploy.yml
+++ /dev/null
@@ -1,166 +0,0 @@
-name: Docs Deploy
-
-# Builds the docs site (`apps/site`, the Vercel project `objectui`, served at
-# objectui.org) on a GitHub-hosted runner and uploads the prebuilt output to
-# Vercel as a production deployment. Vercel itself builds nothing.
-#
-# ## Why the build moved here (objectui#12178)
-#
-# On the maintainer's Vercel bill almost all of the cost was "Build CPU
-# Minutes". Vercel built this project once for every commit pushed to `main`,
-# plus a preview build for every other branch. The preview builds were
-# cancelled by the Ignored Build Step, and every production build since
-# 2026-09-06 failed (objectui#12178), so all of that spend deployed nothing.
-# This repository is public, so `ubuntu-latest` minutes cost nothing. The
-# maintainer decided the site deploys only from `main`, on free compute.
-# `apps/site/vercel.json` turns Vercel's Git-triggered deployments off
-# (`git.deploymentEnabled: false`), so this workflow is now the ONLY way the
-# site gets deployed.
-#
-# ## When it runs: every 6 hours, or on demand
-#
-# There is no `push` trigger, on purpose. The maintainer chose a fixed clock:
-# at most 4 builds a day, no competition with pull-request CI for runners,
-# and no `paths` list to keep in step with the site's build closure. The docs
-# can therefore be up to 6 hours behind `main`. To publish now, use "Run
-# workflow" on this workflow's Actions page (or
-# `gh workflow run docs-deploy.yml --ref main`).
-#
-# The cron is `43 5,11,17,23 * * *`, chosen against
-# `grep -rn 'cron:' .github/workflows/`. Hours 0/6/12/18, 1/7/13/19 and
-# 2/8/14/20 each already carry another 6-hourly job, while 5/11/17/23 meet
-# only one daily patrol (05:23). Minute 43 is off the top of the hour, where
-# scheduled runs bunch and get delayed, and clear of the 15-minute merge-queue
-# patrol's :37 and :52. That census was read once, when this was written, and
-# nothing re-checks it; re-run the grep before you rely on it.
-#
-# ## Which way it fails
-#
-# Red, never a quiet green. With a missing `VERCEL_TOKEN`, a ref other than
-# `main`, a failed build or a failed upload, the job goes red with an
-# `::error::` saying which. A run that skipped and still went green would stop
-# publishing the docs with nothing to show for it, which is the failure that
-# froze the site for five weeks (objectui#12178).
-#
-# ## How the Vercel CLI is driven
-#
-# - The project's Root Directory is `apps/site`, so all three commands run from
-# the REPOSITORY ROOT. `vercel pull` writes the project settings, including
-# that Root Directory, to `./.vercel/`. `vercel build` then builds in
-# `apps/site`, reads `apps/site/vercel.json` (whose `installCommand` and
-# `buildCommand` override the dashboard) and writes `./.vercel/output`.
-# `vercel deploy --prebuilt` uploads that directory. Running the commands
-# from inside `apps/site` would apply the Root Directory twice.
-# - `VERCEL_ORG_ID` / `VERCEL_PROJECT_ID` identify the team and project. They
-# are identifiers, not secrets.
-# - `VERCEL_TOKEN` reaches only the two steps that call the Vercel API (`pull`
-# and `deploy`). `vercel build` needs no token, and it runs `pnpm i` plus the
-# whole workspace build, which means every dependency's install scripts. So
-# it is deliberately not given the token.
-# - The CLI is pinned (`VERCEL_CLI_VERSION`) so that a CLI release cannot
-# change what a scheduled run does. Bump it on purpose.
-#
-# The build itself is the one `ci.yml`'s `Build Docs` job runs
-# (`pnpm turbo run build --filter=@object-ui/site`, via
-# `apps/site/vercel.json`), so a green `Build Docs` on `main` predicts a green
-# build here.
-
-on:
- schedule:
- - cron: '43 5,11,17,23 * * *'
- workflow_dispatch: {}
-
-permissions:
- contents: read
-
-# A later run builds the tip of `main`, which contains everything an earlier
-# run would have deployed, so an in-flight run is safe to cancel.
-concurrency:
- group: docs-deploy-production
- cancel-in-progress: true
-
-jobs:
- deploy:
- name: Deploy docs to Vercel (production)
- runs-on: ubuntu-latest
- timeout-minutes: 30
- env:
- VERCEL_ORG_ID: team_06nvlGKZCkwhzdXmRdxeCNnB
- VERCEL_PROJECT_ID: prj_v1rXIczaI7YGmXmseQCLFUFOFxTB
- VERCEL_CLI_VERSION: 60.1.3
- steps:
- # Before anything else, so that a run which cannot deploy fails in
- # seconds, and fails red.
- - name: Refuse to run without VERCEL_TOKEN, or from a ref other than main
- env:
- VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
- run: |
- if [ "${GITHUB_REF}" != 'refs/heads/main' ]; then
- echo "::error title=Docs deploy runs from main only::This run is on ${GITHUB_REF}. The docs site deploys only from main (objectui#12178); dispatch the workflow with --ref main."
- exit 1
- fi
- if [ -z "${VERCEL_TOKEN}" ]; then
- echo "::error title=VERCEL_TOKEN is missing::The secret VERCEL_TOKEN is not available to this run, so nothing can be deployed and objectui.org stays on its last deployment. Create a Vercel token scoped to the Object Stack team and store it as an Actions secret named VERCEL_TOKEN (repository or organization level)."
- exit 1
- fi
-
- - name: Checkout code
- uses: actions/checkout@v7
-
- - name: Enable Corepack and download the pinned pnpm
- run: bash scripts/ci-setup-pnpm.sh
-
- - name: Setup Node.js
- uses: actions/setup-node@v7
- with:
- node-version: '22.x'
- cache: 'pnpm'
-
- # objectui#12957: every @objectstack/* package resolves to the build of the
- # commit `.objectstack-sha` pins, read from node_modules/.cache/objectstack, so
- # it must be there before pnpm resolves anything. Restored by the pin and built
- # only on a miss (scripts/objectstack-pin.mjs); the save is this job's last step.
- - name: Restore the pinned @objectstack build
- id: objectstack-pin
- uses: actions/cache/restore@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
-
- - name: Build @objectstack at the pin (skips a restored build)
- id: objectstack-build
- run: node scripts/objectstack-pin.mjs build
-
- - name: Install the Vercel CLI
- run: npm install --global "vercel@${VERCEL_CLI_VERSION}"
-
- - name: Pull the production project settings
- env:
- VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
- run: vercel pull --yes --environment=production
-
- - name: Build the site
- run: vercel build --prod
-
- - name: Deploy the prebuilt output to production
- env:
- VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
- run: |
- url=$(vercel deploy --prebuilt --prod)
- echo "Deployed: ${url}"
- echo "### Docs deployed to production" >> "$GITHUB_STEP_SUMMARY"
- echo "" >> "$GITHUB_STEP_SUMMARY"
- echo "${url} (commit ${GITHUB_SHA})" >> "$GITHUB_STEP_SUMMARY"
-
- # The pinned @objectstack build, saved only when this job built it (a cache
- # miss) and whatever the verdict above was -- it does not depend on it.
- # Bookkeeping: bounded and non-fatal (objectui#6577, objectui#7048). The bound
- # is not yet derived from a measured save; re-derive it from this step's runs.
- - name: Save the pinned @objectstack build
- if: ${{ !cancelled() && steps.objectstack-build.outcome == 'success' && steps.objectstack-pin.outputs.cache-hit != 'true' }}
- continue-on-error: true
- timeout-minutes: 2
- uses: actions/cache/save@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
diff --git a/ui/.github/workflows/governed-surface-guard.yml b/ui/.github/workflows/governed-surface-guard.yml
deleted file mode 100644
index 0dda70c6d4f..00000000000
--- a/ui/.github/workflows/governed-surface-guard.yml
+++ /dev/null
@@ -1,127 +0,0 @@
-# The machine half of the governed-surface no-bypass rule (objectui#6596,
-# maintainer ruling 2026-08-27 accepting Option C on #6325). The rule, the event
-# split, the approval predicate and the exit contract all live in
-# `scripts/check-governed-queue-guard.mjs`; that header is authoritative and this
-# file is the invocation. Only the wiring decisions are argued here.
-#
-# What it is for, in one measurement: PR #6183 touched `AGENTS.md`, was correctly
-# parked as a draft, and a GitHub MCP `update_pull_request` call passing only
-# `reviewers` silently set `draft: false`. The pull request entered the merge
-# queue and landed as `5b3290fd5` with no human approval; converting it back to a
-# draft did NOT dequeue it. Nothing in this repository could have refused that —
-# the whole defence was seat discipline, and the failure contained no seat
-# decision at all.
-name: Governed Surface Guard
-
-on:
- # BOTH legs are load-bearing and they mean DIFFERENT things — see the script
- # header. `merge_group` is the leg that REFUSES: the queue build is the last
- # thing between a speculative merge and `main`, and it is the path #6183 took.
- # `pull_request` is an EARLY WARNING that deliberately exits 0, because a
- # governed PR held as a draft for the maintainer to merge by hand is this
- # regime's healthy end state, and a check that reddens on the healthy case is
- # a permanently red check nobody reads.
- pull_request:
- # `develop` is included for the same reason every other requirable gate here
- # names both: a context that does not report on a branch it could be
- # required on leaves the pull request pending rather than failing it.
- branches: [main, develop]
- # ⚠️ Naming `types:` REPLACES GitHub's default set rather than extending it,
- # so all three defaults are restated. `ready_for_review` is the addition and
- # it is the point: flipping a governed draft to ready is the first move of
- # the exact sequence this guard exists to interrupt, and it is not in the
- # default set — without it the warning would not re-fire at the one moment a
- # seat most needs to read it. `scripts/check-governed-queue-guard.mjs
- # --self-test` pins this line.
- types: [opened, synchronize, reopened, ready_for_review]
- # Merge queue (objectui#3523 — see `ci.yml`'s trigger block for the full note
- # and the measurements behind it). This is the leg that refuses, and a
- # requirable context that does not report on a queue build stalls the queue
- # until the ruleset's 60-minute status-check timeout. `types:` is named
- # although `checks_requested` is currently the only one GitHub defines.
- merge_group:
- types: [checks_requested]
-
-# ⛔ NO `paths:` filter, on either leg, and this is not an oversight. A skipped
-# job counts as SUCCESS in branch protection, so a path filter would hand the
-# queue a green "Governed Surface Queue Guard" for a pull request the filter
-# mis-scoped — on the one check whose entire job is to refuse. The path test
-# belongs INSIDE the script, where "nothing governed" is a verdict that says so
-# and costs zero API calls. (`merge_group` supports no path filter at all, so a
-# filter would also make the two legs disagree about what they cover.) The
-# script's `--self-test` fails if one is ever added; the same conclusion
-# `control-bytes.yml` and `changeset-presence.yml` reached for themselves.
-
-concurrency:
- group: governed-surface-guard-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
-
-# The default read scopes and nothing beyond them. `pull-requests: read` is what
-# the review and head reads need; there is no write scope here, and adding one
-# to let this check "fix" anything would be a widening no ruling covers.
-permissions:
- contents: read
- pull-requests: read
-
-jobs:
- governed-surface-guard:
- # ⚠️ THIS LITERAL IS THE CHECK-RUN NAME branch protection would pin, and it
- # is duplicated in `CHECK_CONTEXT_NAME` in the script — deliberately, and
- # pinned in both directions: the script's `--self-test` reads THIS FILE and
- # fails if the two ever disagree. Renaming a job silently detaches a
- # required context, and a name that lives in exactly one place is a name
- # nothing can pin. It is also registered in `REQUIRED_CONTEXTS` in
- # `scripts/dependabot-merge-gate.mjs`, which is this repository's own
- # written-down answer to "which checks are blocking".
- #
- # ⛔ Making it a REQUIRED context in the live branch-protection ruleset is
- # not this pull request's step and cannot be: that is a repository-settings
- # surface only the maintainer can change. Until it is flipped, this job
- # REPORTS on a queue build without stopping it.
- name: Governed Surface Queue Guard
- runs-on: ubuntu-latest
- timeout-minutes: 10
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v7
- with:
- # A governed-surface question answered over a truncated history
- # answers with SILENCE, and silence reads as compliance. A merge
- # group's base sha can predate a shallow fetch's floor, so the whole
- # history is the only depth that cannot under-report. The script
- # refuses outright (exit 1) if either sha is missing, rather than
- # diffing what it happens to have.
- fetch-depth: 0
-
- - name: Setup Node.js
- uses: actions/setup-node@v7
- with:
- node-version: '22.x'
-
- # The self-test runs FIRST and in its own step, because it is the
- # precondition for trusting the run after it: this guard's predicates
- # decide whether a merge lands, so a rotted predicate must redden here
- # rather than quietly wave a governed diff through. It also holds the
- # wiring pins — the job name above, the triggers, `fetch-depth: 0`, the
- # absent path filter, and the `REQUIRED_CONTEXTS` registration.
- #
- # No install and no build: the script imports node builtins and one local
- # module (`scripts/invoked-as.mjs`) only, which is also what keeps it
- # clear of `scripts/check-pre-install-import-graph.mjs`.
- - name: Guard predicate self-test
- run: node scripts/check-governed-queue-guard.mjs --self-test
-
- # The live judgment. Everything it reads arrives through `env:` or the
- # event payload on disk — no `${{ }}` interpolation into the shell line,
- # so no pull-request-controlled text ever becomes part of a command.
- #
- # GITHUB_TOKEN is the workflow's own default token at the read scopes
- # declared above; it is what makes the review read possible at all, and an
- # unreadable review list is a REFUSAL with its own exit code, never a
- # pass. ⛔ Fail-open is wrong in this file specifically — it exists
- # because every other layer in this chain failed open.
- - name: Governed surfaces may not enter the merge queue unreviewed
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: node scripts/check-governed-queue-guard.mjs
diff --git a/ui/.github/workflows/half-state-patrol.yml b/ui/.github/workflows/half-state-patrol.yml
deleted file mode 100644
index 47b3c839ab1..00000000000
--- a/ui/.github/workflows/half-state-patrol.yml
+++ /dev/null
@@ -1,152 +0,0 @@
-name: Half-State Patrol
-
-# This board's standing caller of objectstack's half-state patrol: the composite
-# action `objectstack-ai/objectstack/.github/actions/half-state-patrol`, pinned
-# to an objectstack commit sha (objectui#11174, objectui's half of
-# objectstack-ai/objectstack#18471).
-#
-# ## What lives here, and what does not
-#
-# This file holds only what is THIS board's: when the patrol runs, what it may
-# touch, and the inputs whose values are decided here. Everything else — the
-# sweeper (objectstack's `scripts/pm/check-half-states.mjs`), how its findings
-# are rendered and delivered, and when a run goes red — is the action's, and
-# the action's own header is the authority on it. ⛔ Do not restate that
-# behaviour here. A copy of upstream prose describes upstream as of the day it
-# was copied and no later: objectui#10208 retired this repository's copy of the
-# sweeper for that reason, and objectui#11174 retired the copied steps and the
-# prose adopted with them.
-#
-# ## The pin
-#
-# The `uses:` ref is a 40-character objectstack commit sha, ⛔ never `@main`:
-# `@main` would adopt whatever landed upstream an hour ago, with no reviewed
-# moment in this repository and nothing to roll back to. A composite action
-# referenced at a sha makes the runner place the WHOLE objectstack repository
-# at that sha, so the pin freezes the sweeper as well as `action.yml`.
-#
-# TO BUMP: pin objectstack `main`'s tip as read at that moment, and record the
-# sha and the time it was read in the pull request. ⛔ Not the action's own last
-# commit — that would roll the sweeper back to wherever it stood when
-# `action.yml` last changed (objectui#11174, the seat's ruling on which sha to
-# pin). The pin must contain objectstack `79114850f`, where `anchor-optional`
-# was declared: an older one receives that input as an unexpected one, which
-# the runner only warns about, and every scheduled run here goes red.
-#
-# The sha spelling is a declared exception to this repository's action-ref
-# convention: `DECLARED_EXCEPTIONS` in `scripts/check-action-ref-convention.mjs`
-# carries this workflow, this action and the reason.
-#
-# ## No anchor issue, by decision (objectui#8740)
-#
-# This board has no pinned anchor issue: the maintainer declined the setup and
-# closed objectui#7852 for it. The step below says so BY
-# NAME rather than by omission — `anchor-issue` is passed empty and
-# `anchor-optional: true` declares empty to be this board's supported
-# configuration (objectstack-ai/objectstack#20793, decision A). The action then
-# sweeps, puts the rendered findings in the run summary, emits a `::notice::`,
-# skips the anchor write and passes. An issue number is only ever meaningful in
-# the repo it was minted in, which is why nothing here guesses one.
-#
-# ⚠️ The accepted cost, stated rather than rediscovered: the findings live only
-# in run summaries, which notify nobody — objectui#5791 measured what that
-# silence costs on this board. A seat reading this board's half-states reads the
-# patrol's run summaries; there is no anchor to read.
-#
-# ⛔ The opt-in is empty-only, and the action enforces that, not this file: a
-# non-numeric `anchor-issue`, or a sweep that could not run, still fails the
-# run. Delivering to an anchor is one input away (a `tracking`-labeled issue's
-# number as `anchor-issue`), but it reverses the maintainer's decline, so it is
-# the maintainer's call.
-#
-# ## The closed-card floor (objectui#5985)
-#
-# `closed-floor` holds the sweeper's closed-card reader (`pm:*` labels left on
-# cards that already closed) to cards closed on or after 2026-08-28 — the date
-# the strip-on-close convention was written into the pm-dispatch protocol's
-# label-discipline section. `pm:*` on a card closed before the convention
-# existed is inert history, since the dispatch loop reads state on OPEN cards
-# only. The measurement taken on 2026-08-24 says what an unfloored reader does
-# here: 815 closed cards carried `pm:dispatched`, and ~87% of the issues in the
-# reader's window carried some `pm:*` residue, against the 26% upstream
-# measured — rows that report the convention rather than a defect, and that
-# consume the whole body budget. Judging from the cutover forward buys the
-# row's value with no backfill: ⛔ no bulk relabelling of closed cards was run
-# and none is owed.
-#
-# ⛔ Do not move the date EARLIER without re-measuring: every day it moves back
-# pulls in cards closed under no convention at all. The floor is UPSTREAM code;
-# what is this repository's own is the value passed to it.
-
-on:
- schedule:
- # Four times a day, six hours apart, at :37 past the hour.
- #
- # The minute is offset ON PURPOSE. The triage Routine that heals these same
- # states fires hourly near the top of the hour, and a patrol landing at the
- # same minute would keep reading the board mid-heal — reporting half-states
- # the healer is in the middle of pairing, i.e. manufacturing findings that
- # clear themselves. :37 puts this sweep in the quiet part of the healer's
- # cycle in both directions. Four runs a day rather than hourly keeps it
- # cheap on the core API quota it shares with the dispatch loop's hot path.
- - cron: '37 1,7,13,19 * * *'
- workflow_dispatch: {}
- # A change to the patrol is exercised before it merges: on a pull_request run
- # the action still sweeps, proving the pin, the inputs and the transport on a
- # real runner, but it skips both anchor steps and writes no issue.
- #
- # ⚠️ That skip means a pull_request run cannot show the no-anchor opt-in at
- # all. The first scheduled or dispatched run after a change here is the live
- # reading of it: `configured=false`, a `::notice::`, the anchor write skipped,
- # and the run green.
- pull_request:
- paths:
- # The action and the sweeper both arrive at the pinned sha, so this file
- # is the only path in this repository a change can break the patrol
- # through — and bumping the pin is an edit to it.
- - '.github/workflows/half-state-patrol.yml'
-
-# What the action asks of its caller's token: `contents: read`, and
-# `issues: write` for its single anchor-body PATCH. With no anchor configured
-# here that write is never attempted, and the token is never used for labels,
-# comments, assignees or state.
-permissions:
- contents: read
- issues: write
-
-# One patrol at a time. A scheduled run overlapping a manual dispatch would read
-# the board twice on one request budget and, were an anchor ever configured,
-# race to rewrite the same body — the loser's findings vanishing with no trace
-# but an edit-history entry.
-concurrency:
- group: half-state-patrol
- cancel-in-progress: false
-
-jobs:
- patrol:
- name: Live half-state sweep
- runs-on: ubuntu-latest
- timeout-minutes: 15
- steps:
- # The SUBJECT of the sweep, not a source of the code that runs it: the
- # sweeper reads this board's own workflows and tracked files from this
- # checkout, and refuses to run without it.
- - name: Checkout repository
- uses: actions/checkout@v7
-
- # Stays in the caller: the action holds no Node pin of its own and only
- # refuses a runner older than the floor its own tree declares.
- - name: Setup Node.js
- uses: actions/setup-node@v7
- with:
- node-version: '22'
-
- - name: Sweep the board
- uses: objectstack-ai/objectstack/.github/actions/half-state-patrol@0c5a71b0942d54e56ad365f0b2a173fd84c1c5e1
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- # Empty on purpose, with the opt-in beside it: see "No anchor issue,
- # by decision" above.
- anchor-issue: ''
- anchor-optional: true
- closed-floor: '2026-08-28'
diff --git a/ui/.github/workflows/hook-selftests.yml b/ui/.github/workflows/hook-selftests.yml
deleted file mode 100644
index b016b1c1ad6..00000000000
--- a/ui/.github/workflows/hook-selftests.yml
+++ /dev/null
@@ -1,207 +0,0 @@
-name: Hook Self-Tests
-
-# The standing caller for `.claude/hooks/*.selftest.sh` — objectui#5754.
-#
-# ── The gap this closes ─────────────────────────────────────────────────────
-#
-# `.claude/hooks/guard-main-checkout-bash.sh` and `.claude/hooks/guard-shared-stash.sh`
-# are the PreToolUse guards behind the rule both `CLAUDE.md` files state as binding —
-# worktree-first, and never `git stash` (see those files' "⛔" sections, and
-# AGENTS.md §9). Each ships a hermetic self-test matrix
-# (`guard-main-checkout-bash.selftest.sh`, `guard-shared-stash.selftest.sh`) that
-# needs only `jq` and `git` — no install, no build, no network — and both files'
-# own headers say to run the matching self-test after touching the hook. Nothing
-# ever did: `git grep -rn selftest .github/workflows/` returned no matches before
-# this workflow existed. A hook is not imported by any package, so no unit test,
-# type check, or lint reaches it either (`eslint.config.js` is scoped to
-# `**/*.{ts,tsx}` throughout, and there is no `shellcheck` anywhere in this repo).
-# The self-test matrix was therefore exercised only when a human — or an agent —
-# remembered, in both repos, and a regression in either direction is invisible
-# until it bites: fail-open silently stops guarding the shared checkout, fail-closed
-# trains an operator onto the `OS_ALLOW_MAIN_EDITS=1` / `OS_ALLOW_STASH=1` escape
-# hatch, switching the guard off for the whole command. Neither shows up in a PR.
-#
-# ── What this job is, and is not ────────────────────────────────────────────
-#
-# This is a RUNNER, not a rewrite of what it runs. It does not touch the hooks or
-# their self-tests — `.claude/**` is governed surface, and the self-test matrices
-# already assert their own pass/fail per case; this job only gives that matrix
-# a caller that runs on every PR the matrix could catch a regression on, and
-# turns "which self-tests failed" into a red check instead of scrollback nobody
-# reads. If a self-test itself is ever found broken, that is a finding for a
-# *separate* PR against the governed surface, not something to patch here.
-#
-# ── ⭐ DISCOVERED at run time, never listed (objectui#6906) ──────────────────
-#
-# The single `run:` step below finds its matrices with
-# `find .claude/hooks -type f -name '*.selftest.sh'`. It used to be a hand-kept
-# ENUMERATION of one `run:` step per matrix, and that shape was this workflow's
-# own defect one level up: the name at the top of this file claims to be the
-# standing caller for all of them, and nothing made the claim true. A new matrix
-# shipped UNCALLED until someone remembered to edit this file — measured, not
-# argued: `guard-tree-enum.selftest.sh` landed in both repos on the same card and
-# objectstack's collector picked it up with no workflow edit, while here it had
-# to be added by hand.
-#
-# That defect is structural rather than carelessness, and the header below
-# already named the cause: this file is a RUNNER, so the matrices it calls are
-# meant to grow in `.claude/**` WITHOUT this file being touched (both #6042 /
-# PR #6087 and #5789 / PR #6046 do exactly that, and are right to). Discovery is
-# what finally makes that independence hold for a whole new matrix FILE and not
-# only for cases inside an existing one. It also unblocks #6451 —
-# `guard-main-checkout.sh` has no self-test at all, and whoever writes that
-# matrix would otherwise have to remember this file or watch their work never
-# run.
-#
-# `find`, not a flat glob, so a matrix in a subdirectory is not a silent miss
-# either. Ported from objectstack's collector in its `lint.yml`
-# ("Claude hook guard self-tests"), including both of the load-bearing
-# properties below — a port that drops either is worse than no change.
-#
-# ⭐ 1. An EMPTY discovery is RED, not green. A renamed, moved or deleted
-# directory would otherwise make this step pass by running nothing,
-# which is the identical green line this whole workflow exists to
-# distrust: a step that verified nothing is not a pass. The count is
-# printed on success too, so a SHRINKING set is visible in the log
-# rather than inferred.
-#
-# ⭐ 2. Collected, not sequenced. A `run:` block executes under `bash -e`, so
-# a bare `for … do "$selftest"; done` would abort at the FIRST red
-# matrix and leave the rest unrun — neither green nor red, and nothing
-# in the log tells those apart. The matrices are independent by
-# construction (each builds its own throwaway fixture), so collecting
-# loses nothing and the step still FAILS when any of them does, naming
-# every one that failed. ⛔ Do not "simplify" it into a bare loop.
-#
-# ── Why path-filtered, unlike the control-byte gate ─────────────────────────
-#
-# The control-byte gate (`control-bytes.yml`, a step of `repo-gates.yml` since
-# objectui#12214) explains at length why IT carries no `paths`
-# filter: a markdown-only PR can trip a raw control byte just as easily as a
-# TypeScript one, so an unfiltered gate is the only kind that can see it. That
-# reasoning does not transfer here — the self-tests build their own hermetic
-# git fixtures and assert the CURRENT hook script's behaviour; nothing about a
-# docs-only or dependency-bump PR can move that needle. So this workflow mirrors
-# `changeset-guard.yml`'s shape instead: it fires only on a PR (or push to `main`)
-# that touches `.claude/hooks/**` or this workflow file itself.
-#
-# ── Cost — a dated measurement, not a live claim ─────────────────────────────
-#
-# Measured 2026-08-24 on `origin/main` @ 53dc89db8:
-# `guard-main-checkout-bash.selftest.sh` (100 cases) ~3.2s wall,
-# `guard-shared-stash.selftest.sh` (32 cases) ~0.6s wall. Both need only `jq`
-# and `git`, both preinstalled on `ubuntu-latest` — no `setup-node`, no
-# install, no build.
-#
-# Those counts stay, and the distinction is deliberate (objectui#6089): a
-# measurement is HISTORY. It is read together with its date and its sha, and a
-# matrix that grows afterwards does not make it false — it makes it older. Both
-# have grown since (121 and 41 as of 2026-08-31, plus a third matrix), which is
-# exactly what a dated measurement is allowed to do. A step NAME, by contrast,
-# is a LIVE CLAIM about the run it labels, so it carries no count at all. See
-# the next section.
-#
-# ── Why the step name carries no case count ──────────────────────────────────
-#
-# The `run:` step below is named for what it does and nothing else. Do not add
-# "(N cases)" or "(N matrices)" to it — this file used to name one step per
-# matrix WITH its case count, and it was the declared≠actual shape:
-#
-# - Nothing derived those numbers and nothing re-checked them. A step name
-# has no runtime behaviour, so a wrong count never turns a check red; it
-# just shows a wrong number on the checks page, which is the one place a
-# reviewer actually looks.
-# - The number is already published by the only thing that cannot get it
-# wrong: the step prints how many matrices it discovered, and each run's
-# own tail prints `N passed, N failed`, produced by the matrix being
-# counted.
-# - Where a count belongs beside prose, the hook header owns it WITH the
-# recipe to re-derive it: `guard-shared-stash.sh` states its total, how to
-# re-derive it (`grep -c '^expect ' ` plus the inline specials)
-# and that it must equal the run's tail. `guard-main-checkout-bash.sh`
-# describes its matrix with no count at all. A second copy here would be a
-# second thing to keep in sync, and the one with no recipe behind it.
-#
-# The same reasoning is why `scripts/dependabot-merge-gate.mjs`'s
-# `OPTIONAL_CONTEXTS` description of this check is count-free, and must stay so.
-#
-# ── Fail-closed, deliberately ────────────────────────────────────────────────
-#
-# Each self-test script already exits non-zero on any failing case (its own
-# `pass`/`fail` tally), and the collector below propagates that into the step's
-# own exit status, so the job — and therefore the workflow — goes red the moment
-# any matrix regresses. A gate that ran these and reported without failing the
-# build would reproduce exactly the defect this workflow exists to close.
-# ⛔ Never let the collector swallow an exit code: a green step over a red
-# self-test looks identical to success.
-
-on:
- pull_request:
- branches: [main, develop]
- paths:
- - '.claude/hooks/**'
- - '.github/workflows/hook-selftests.yml'
- push:
- branches: [main]
- paths:
- - '.claude/hooks/**'
- - '.github/workflows/hook-selftests.yml'
- workflow_dispatch:
-
-concurrency:
- group: hook-selftests-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
-
-permissions:
- contents: read
-
-jobs:
- hook-selftests:
- name: Hook Self-Tests
- runs-on: ubuntu-latest
- timeout-minutes: 5
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v7
-
- # Every matrix under `.claude/hooks/` is hermetic: it builds its own
- # throwaway git repo, worktree and non-repo directory under $TMPDIR, and
- # exits non-zero on any failing case. Discovery + the two load-bearing
- # properties are argued in the header sections above — read them before
- # editing this block.
- - name: Claude hook guard self-tests
- run: |
- mapfile -t selftests < <(find .claude/hooks -type f -name '*.selftest.sh' | sort)
- if [ "${#selftests[@]}" -eq 0 ]; then
- echo "Hook self-tests — DISCOVERED NOTHING under .claude/hooks/."
- echo "This step verified nothing, which is a failure and not a pass:"
- echo "the matrices were moved, renamed or deleted. Re-point the search"
- echo "above rather than deleting the step."
- exit 1
- fi
- echo "discovered ${#selftests[@]} hook self-test(s):"
- printf ' %s\n' "${selftests[@]}"
- echo ""
- failed=""
- run_self_test() {
- echo "-- $*"
- if "$@"; then
- echo "PASS $*"
- else
- echo "FAIL $*"
- failed="${failed} $*"$'\n'
- fi
- return 0
- }
- for selftest in "${selftests[@]}"; do
- run_self_test "$selftest"
- done
- if [ -n "$failed" ]; then
- echo ""
- echo "Hook self-tests — the following FAILED:"
- printf "%s" "$failed"
- exit 1
- fi
- echo ""
- echo "Hook self-tests — all ${#selftests[@]} ran and passed"
diff --git a/ui/.github/workflows/labeler.yml b/ui/.github/workflows/labeler.yml
deleted file mode 100644
index 6b6709f7720..00000000000
--- a/ui/.github/workflows/labeler.yml
+++ /dev/null
@@ -1,84 +0,0 @@
-name: Auto Label PRs
-
-on:
- pull_request:
- types: [opened, synchronize, reopened]
-
-permissions:
- contents: read
- pull-requests: write
- # `pull-requests: write` only lets the action add labels that ALREADY EXIST in the
- # repository; creating one it has never seen needs `issues: write`. Upstream states
- # both halves — actions/labeler v7 README "Recommended Permissions", and its
- # src/labeler.ts, which on a 403 raises "The workflow does not have permission to
- # create labels. Ensure the 'issues: write' permission is granted".
- #
- # This is load-bearing here rather than defensive: `.github/labeler.yml` gained 14
- # `package: *` rules in objectui#7746, and 12 of those labels did not exist in this
- # repository's label registry when they were added. The action applies the whole set
- # in ONE `setLabels` call, so without this the first PR touching e.g. `packages/auth`
- # fails the call outright and the PR gets NO labels at all — strictly worse than the
- # coverage gap #7746 closed. Pinned by scripts/__tests__/labeler-package-coverage.test.ts.
- #
- # With `sync-labels: true` and a run on every push, which labels that call keeps decides
- # whether a hand-hung label this config never names (`needs:contract-review`,
- # `needs-user-decision`) outlives the next push. Measured once on objectui#10410
- # (a024b78 -> a7bac39): a hand-hung `question` survived the `synchronize` run whose
- # `setLabels` added `configuration` and `ci/cd`, so it does. One-time reading, nothing
- # re-derives it; a label hung while a run is in flight was not measured.
- issues: write
-
-jobs:
- label:
- runs-on: ubuntu-latest
-
- # ── The job ceiling, DERIVED FOR THIS JOB (objectui#7956) ────────────
- # Before this line the only backstop was GitHub's 360-minute default.
- #
- # ⚠️ The stakes here are NOT objectui#7270's. That card's argument leaned on
- # two of its three jobs producing a REQUIRED context on a shared serial
- # queue. Re-measured on `GET /repos/objectstack-ai/objectui/rules/branches/
- # main` at 2026-09-06T17:20Z, the required checks are `Lint`, `Type Check`,
- # `Build & E2E`, `Test (shard 1/4)` through `(4/4)`, `Build Docs` and
- # `Changeset Declaration` — this job produces none of them, and it blocks
- # nothing. What is left is the generic exposure: a wedged job holds a runner
- # for six hours and then reports `cancelled`. This job runs on every push to
- # every open pull request, so it is the most frequently started of the six
- # objectui#7956 covers and the cheapest place for that exposure to be paid
- # repeatedly.
- #
- # ⛔ NOT inherited from `ci.yml`'s 10/15/20/30/40, nor from the ceilings
- # objectui#7270 derived — objectui#7048 fences exactly that. The arithmetic
- # below is this job's own.
- #
- # - Population: the last 300 successful runs of this workflow, window
- # 2026-09-04T15:06Z .. 2026-09-06T17:19Z, n=300 `label` jobs. Per-job
- # wall clock from the Actions jobs endpoint (`completed_at` minus
- # `started_at`), never the run's total.
- # - min 3s / median 5s / p95 8s / max 13s.
- # - Single event (`pull_request`, n=300), so there is no by-event split to
- # report and one ceiling is all there is to set.
- # - max/p95 = 1.6 — no fat tail, so the multiplier is not widened.
- #
- # Ceiling = the smallest round number that is both >= 3x max (0.7min) and
- # >= max + 15min (15.2min) => 20. That is ~92x the slowest run ever observed
- # here, so it cannot fire on a healthy-but-slow runner; a wedge dies in 20
- # minutes instead of 6 hours.
- #
- # ⚠️ Four of the six jobs objectui#7956 bounded land on 20 by this same
- # arithmetic. That is NOT one number copied across them, which precondition
- # ② of that card's triage forbids: for any job whose slowest run is under
- # 7.5 minutes the additive term dominates the multiplicative one, so the
- # rule puts every sub-minute job on the same rung. Each of the four carries
- # its own sample and its own derivation beside its own key.
- #
- # ⛔ Raising this toward 360 is the ruled-out non-fix (objectui#6577,
- # objectui#7048): the hang just runs longer and the gate still reports
- # `cancelled`.
- timeout-minutes: 20
- steps:
- - uses: actions/labeler@v7
- with:
- repo-token: ${{ secrets.GITHUB_TOKEN }}
- configuration-path: .github/labeler.yml
- sync-labels: true
diff --git a/ui/.github/workflows/line-citation-gate.yml b/ui/.github/workflows/line-citation-gate.yml
deleted file mode 100644
index a15101980b7..00000000000
--- a/ui/.github/workflows/line-citation-gate.yml
+++ /dev/null
@@ -1,80 +0,0 @@
-name: Line Citation Gate
-
-# The DIFFERENTIAL cross-file line-address citation gate (objectui#8875, ruling
-# clause 2). It reads what THIS pull request ADDED against its base — never a
-# tree-wide total. An absolute count was explicitly refused: PR #8887's line
-# shifts flipped one citation from `drifted` to `resolves` by accident, moving
-# the tree-wide false count 540 -> 539, and that unearned green belonged to
-# nobody. The 540 existing citations are not this gate's denominator.
-#
-# ── REPORT-ONLY, and the workflow does not decide that ───────────────────────
-# The enforcement state lives in the script (`ENFORCEMENT`), not here, so there
-# is one place to read it and one place to flip it. Report-only means findings
-# exit 0; a failed CONTROL still exits 1 in both modes, because a differential
-# gate reporting zero through a broken differ is indistinguishable from a clean
-# branch — the failure this card names one level up.
-#
-# ── Why its own workflow rather than a step in `lint.yml` ────────────────────
-# `lint.yml` decides inside the job whether the change "needs a full run", and
-# its exclusion list skips every expensive step on a markdown-only, docs-only or
-# changeset-only change. Those are precisely the changes that carry citations:
-# the ruling names 注释、消息串、文档、changeset、脚本 in that order. A gate
-# that cannot see the pull request shape most likely to trip it rebuilds the
-# hole it exists to close — the same conclusion the shell-escape, docs-link and
-# control-byte gates (folded into `repo-gates.yml` by objectui#12214) and
-# `changeset-presence.yml` each reached in their own headers. One gate, one home.
-#
-# Hence no `paths` and no `paths-ignore`, deliberately.
-#
-# ── Why `pull_request` only ──────────────────────────────────────────────────
-# This gate needs a BASE to be differential at all, and only a pull request has
-# one. It is therefore ⛔ not requirable while it is report-only, and
-# `scripts/dependabot-merge-gate.mjs` classifies it in `NOT_A_GATE` with that
-# reason. When `ENFORCEMENT` flips to `blocking` this workflow owes a
-# `merge_group` leg before the context may be required — a required check that
-# does not report on a queue build stalls the queue until the ruleset's
-# 60-minute timeout fails it (objectui#3523).
-#
-# It needs no install and no build: a checkout plus one `node` call over this
-# branch's own diff. Keep it that way; the import graph is node builtins plus
-# repo-relative modules only, which `repo-gates.yml`'s Pre-Install Import Graph
-# Check enforces.
-
-on:
- pull_request:
- branches: [main, develop]
- workflow_dispatch:
-
-concurrency:
- group: line-citation-gate-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
-
-permissions:
- contents: read
-
-jobs:
- line-citation-gate:
- name: Line Citation Gate
- runs-on: ubuntu-latest
- timeout-minutes: 5
-
- steps:
- # `fetch-depth: 0` is load-bearing, not caution: the gate resolves the
- # merge base with the target branch, and a shallow clone cannot. When it
- # cannot, the gate exits 2 naming PREREQUISITE NOT MET rather than
- # printing a zero — "the diff could not be computed" must never be
- # indistinguishable from "nothing was added" (objectstack#4928).
- - name: Checkout code
- uses: actions/checkout@v7
- with:
- fetch-depth: 0
-
- - name: Setup Node.js
- uses: actions/setup-node@v7
- with:
- node-version: '22.x'
-
- - name: Report cross-file line-address citations this pull request adds
- env:
- CITATION_GATE_BASE: origin/${{ github.event.pull_request.base.ref }}
- run: node scripts/check-new-cross-file-line-citations.mjs
diff --git a/ui/.github/workflows/lint.yml b/ui/.github/workflows/lint.yml
deleted file mode 100644
index f1f3abe016a..00000000000
--- a/ui/.github/workflows/lint.yml
+++ /dev/null
@@ -1,635 +0,0 @@
-name: Lint
-
-# Until #2923 this workflow was `workflow_dispatch`-only, so ESLint had never
-# gated a PR. That mattered more than it looked: every `object-ui/*` rule that
-# `eslint.config.js` sets to `error` is a ratchet — added *specifically* so a
-# new violation fails CI, with its existing sites pre-cleaned first so the rule
-# lints clean on the day it lands. While nothing ran them, every one of them was
-# inert.
-#
-# `eslint.config.js` is the single list of those rules, and this comment
-# deliberately neither counts them nor names them: it used to hand-count, and
-# the count was stale by the time anyone read it (#3261). A hand-copied
-# enumeration drifts by construction, and a stale one still reads as
-# authoritative — `content/docs/guide/ci-cd-pipeline.md` avoids the number for
-# the same reason. `scripts/__tests__/lint-workflow.test.ts` holds that in
-# place: it fails if a count or a rule name reappears here, if the config stops
-# setting any `object-ui/*` rule to `error`, or if this workflow stops gating
-# pull requests.
-#
-# `--max-warnings` is deliberately not set: warnings repo-wide run into the
-# thousands, dominated by `no-explicit-any` plus React Compiler rules the config
-# downgrades on purpose — known historical debt, not a signal. This gate is
-# about errors. No exact figure here, for the reason above: this paragraph used
-# to carry a hand-maintained warning count and percentage that nothing
-# recomputed and nothing alarmed on as they aged (#3274), and
-# `scripts/check-lint-coverage.mjs` held a copy of the same number that would
-# have gone stale on its own clock. The order of magnitude is the whole
-# argument; the integer never was.
-on:
- push:
- branches: [main, develop]
- paths-ignore:
- - '**/*.md'
- - 'content/**'
- - 'docs/**'
- - '.changeset/**'
- # No `paths-ignore` here any more (objectui#3523, step 2) — it skipped the
- # whole workflow on a docs-only / changeset-only PR, so the `Lint` context was
- # absent exactly where a required check must still report. The path decision
- # moved into the job below. `push` above keeps its copy: nothing judges a push
- # to `main`.
- pull_request:
- branches: [main, develop]
- # ── Merge queue (objectui#3523) ────────────────────────────────────────
- # The merge queue is ENFORCED on this repository by a ruleset — a direct push
- # to `main` returns 405 `Changes must be made through the merge queue`
- # (measured in #3243). Until this trigger landed, not one of the repository's
- # workflows subscribed `merge_group`: repo-wide `event=merge_group` runs stood
- # at total_count = 0, historically. A queue with nothing subscribed to it can
- # only have an EMPTY required-check set, so it rebuilt each PR on the current
- # `main` and let it through without validating anything.
- #
- # That is not a theoretical hole; it was cashed in on 2026-08-07. #3498 landed
- # a `scripts/` type gate, itself fully green, that left a TS2578 on `main`;
- # #3503, #3510 and #3516 then merged between 02:11Z and 02:15Z with `Type
- # Check` at conclusion=failure, and #3505 hot-fixed the result. objectstack
- # went through the same frames (objectstack#6067 -> #5615).
- #
- # `types:` is spelled out although `checks_requested` is the ONLY activity
- # type GitHub defines for `merge_group` today — the two spellings are
- # equivalent right now (objectstack's `ci.yml` and `lint.yml` use the bare
- # `merge_group:` form and produce queue builds normally, 3552 of them). Naming
- # the type means a second activity type added later cannot silently start
- # queue builds this workflow was never written for.
- #
- # `concurrency` below needs no merge-queue special case, and that was checked
- # rather than assumed: on `merge_group` the `github.event.pull_request` half of
- # the group expression is null, so the group falls back to `github.ref`, which
- # on a queue build is the queue's own generation — measured on objectstack,
- # `gh-readonly-queue/main/pr-6594-251e888ac9ace8226f3a8450951e5b40a0a84c2c`.
- # It can collide with neither a pull-request group (a bare PR number) nor a
- # push group (`refs/heads/main`), so a queue build and the PR build it came
- # from never cancel each other.
- merge_group:
- types: [checks_requested]
- workflow_dispatch:
-
-concurrency:
- group: lint-${{ github.ref }}
- cancel-in-progress: true
-
-permissions:
- contents: read
-
-jobs:
- lint:
- name: Lint
- runs-on: ubuntu-latest
-
- # ── The job ceiling, DERIVED FOR THIS JOB (objectui#7270) ────────────
- # Before this line the only backstop was GitHub's 360-minute default. This
- # repository has been bitten twice by an unbounded step whose only backstop
- # was the job ceiling — the `apt-get` hang (objectui#5304) and the cache-save
- # stall that ejected an all-green pull request (objectui#6577) — and both
- # times the ceiling converted a transient fault into a `cancelled` check,
- # which the merge queue cannot tell from `failure`. `Lint` is a required
- # context on `pull_request` and `merge_group` alike, so at 360 the same
- # fault holds a shared serial queue for six hours.
- #
- # ⛔ NOT inherited from `ci.yml`'s 10/15/20/30/40 — objectui#7048 fences
- # exactly that. The arithmetic below is this job's own.
- #
- # - Population: the last 300 successful runs of this workflow, window
- # 2026-09-05T11:51Z .. 2026-09-06T04:11Z, n=300 `Lint` jobs. Per-job
- # wall clock from the Actions jobs endpoint (`completed_at` minus
- # `started_at`), never the run's total.
- # - min 9s / median 5m16s / p95 5m46s / max 7m14s.
- # - Bimodal at the LOW end only: 11 runs finished in 9-14s, which is the
- # `Decide whether this change needs a full run` gate skipping every
- # step; the other 289 ran 3m27s-7m14s. A low mode cannot raise a
- # ceiling, so it is recorded and not used.
- # - By event, no material difference, so one ceiling covers all three:
- # merge_group n=103 (median 5m17s, max 6m06s), pull_request n=142
- # (median 5m15s, max 6m39s), push n=55 (median 5m14s, max 7m14s).
- # - max/p95 = 1.25 — no fat tail, so the multiplier is not widened.
- #
- # Ceiling = the smallest round number that is both >= 3x max (21.7min) and
- # >= max + 15min (22.2min) => 25. That is ~3.5x the slowest run ever
- # observed here, so it cannot fire on a healthy-but-slow runner; a wedge
- # dies in 25 minutes instead of 6 hours.
- #
- # ⛔ Raising this toward 360 is the ruled-out non-fix (objectui#6577,
- # objectui#7048): the hang just runs longer and the gate still reports
- # `cancelled`.
- timeout-minutes: 25
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v7
- with:
- submodules: true
- # `fetch-depth: 0` for the gate step below (objectui#3523): it diffs
- # against the merge base, which a depth-1 clone cannot resolve.
- fetch-depth: 0
-
- # ── Always report; run only when it matters (objectui#3523) ──────────
- # `on.pull_request.paths-ignore` used to skip this whole workflow on a
- # docs-only or changeset-only pull request, so the `Lint` context was
- # simply absent there — and a required check that never reports leaves the
- # PR pending forever (in the merge queue, until the ruleset's 60-minute
- # timeout fails it). The filter moved from the trigger into the job: the
- # job always runs and always reports, the paths decide only whether the
- # expensive steps execute. `ci.yml`'s `docs` job is the in-repo precedent
- # for the shape, and its `type-check` job carries the long version of this
- # note. The list below IS the `paths-ignore` it replaced; the `push`
- # trigger keeps its copy, because nothing judges a push to `main`.
- #
- # Fails OPEN: if the diff cannot be computed the job runs everything,
- # rather than reporting green having linted nothing (objectstack#4928).
- - name: Decide whether this change needs a full run
- id: relevant
- run: |
- if [ "${{ github.event_name }}" != 'pull_request' ]; then
- echo 'should_run=true' >> "$GITHUB_OUTPUT"
- echo 'Not a pull request: push is filtered at the trigger, and a merge_group build is the last validation before main. Running everything.'
- exit 0
- fi
- if ! CHANGED=$(git diff --name-only \
- '${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}' -- \
- . \
- ':(exclude,glob)**/*.md' \
- ':(exclude,glob)content/**' \
- ':(exclude,glob)docs/**' \
- ':(exclude,glob).changeset/**'); then
- echo 'should_run=true' >> "$GITHUB_OUTPUT"
- echo 'Could not diff against the merge base. Running everything rather than skipping silently.'
- exit 0
- fi
- if [ -n "$CHANGED" ]; then
- echo 'should_run=true' >> "$GITHUB_OUTPUT"
- echo "$CHANGED"
- else
- echo 'should_run=false' >> "$GITHUB_OUTPUT"
- echo 'Only ignored paths changed. Skipping the steps below; this check still reports.'
- fi
-
- - name: Enable Corepack and download the pinned pnpm
- if: steps.relevant.outputs.should_run == 'true'
- run: bash scripts/ci-setup-pnpm.sh
-
- - name: Verify pnpm version
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm --version
-
- - name: Setup Node.js
- if: steps.relevant.outputs.should_run == 'true'
- uses: actions/setup-node@v7
- with:
- node-version: '22.x'
- cache: 'pnpm'
-
- # Every package must run ESLint or be declared a known gap. turbo skips
- # scriptless packages silently, so without this a package reads as clean
- # because nothing linted it. Runs before install: only reads package.json.
- - name: Verify lint coverage
- if: steps.relevant.outputs.should_run == 'true'
- run: node scripts/check-lint-coverage.mjs
-
- # ── One entry guard, one predicate (objectui#6092) ───────────────────
- # A `scripts/**` CLI has to answer "did node run me, or did something
- # import me?" before it does anything, and the hand-typed answers in this
- # tree had drifted into NINE spellings across 28 `.mjs` files. Every one
- # of them fails the same way: node resolves symlinks for the module graph
- # but leaves `process.argv[1]` as the caller typed it, so a script reached
- # through a symlink compares two different paths, answers false, and does
- # NOTHING — exit 0, no output. The wrappers that spawn these tools hold
- # `result.status` only, so an inert child is a green gate. Measured on a
- # real blocking gate in this repository (objectui#6078):
- # `check-skills-paths.mjs` run directly on a deliberately broken tree
- # exits 1 with 696 bytes naming the dead path; run through a symlink
- # against the SAME tree it exits 0 with no output.
- #
- # `scripts/invoked-as.mjs` landed here in #5984 with a header stating that
- # a gate enforced the single-spelling rule. It did not exist, and the
- # sweep it described had not happened either (objectui#6078). This is that
- # gate. It lands BEFORE the conversion on purpose: the worklist grew while
- # the card sat open, so a sweep with nothing under it can be undone
- # silently by the next pull request. The 29 existing guards are baselined,
- # SHRINK-ONLY, and the script's own header says why that is safe.
- #
- # Runs before install, next to `check-lint-coverage.mjs` above and for the
- # same reason: it reads sources with no dependency beyond node builtins
- # and two local modules, so nothing it needs is in `node_modules`. Placed
- # before `pnpm install` it also cannot be made green by an install
- # failure. `--self-test` runs FIRST and is the half that stops the gate
- # rotting into decoration — it drives the scanner over fixture sources,
- # including the nine spellings measured in this tree, and pins the
- # baseline in every direction it can move.
- #
- # Invoked as `node` rather than through the `pnpm check:entry-guard` alias
- # for the pre-install placement, matching `check-lint-coverage.mjs` above
- # and `check-cross-repo-closer-outcome.mjs` below. The alias exists in
- # `package.json` for local use and `scripts/__tests__/entry-guard-wiring.test.ts`
- # holds the two spellings to the same script.
- - name: Verify every scripts/ entry guard goes through one predicate
- if: steps.relevant.outputs.should_run == 'true'
- run: |
- node scripts/check-entry-guard.mjs --self-test
- node scripts/check-entry-guard.mjs
-
- # ── The bash 3.2 floor on this repository's own shell (objectui#7692) ──
- # macOS ships bash 3.2.57 and no bash 4+, for licensing reasons. CI runs
- # bash 5, where every bash-4-only construct works — so a `mapfile`,
- # a `declare -A` or an unguarded `$EPOCHSECONDS` in a hand-run script is
- # invisible to a normal green run: the defect AND its repair both read as
- # green, and the only reader who ever sees the failure is a contributor on
- # a Mac at the moment they most need the script to work.
- #
- # This repository held its shell to nothing. Measured on `origin/main`
- # `28cfff4` with a control beside the zero: 0 tracked files named
- # `bash32`/`bash-32`, against 47 `scripts/check-*.mjs` gates that do exist.
- # `check-control-bytes` judges bytes in every text file and
- # `check-shell-escape-residue` judges markdown FENCES ("206 file(s) and
- # 1309 fenced block(s)"), so neither is this.
- #
- # ⭐ The scan roots are `scripts/**`, `.claude/hooks/**` and `e2e/**`, and
- # the third one is the point rather than a detail: two of the four shell
- # files this repository wrote itself live in `e2e/live/ci/`. Upstream's
- # roots (`.githooks/**` instead of `e2e/**`) would have walked past half
- # the population while printing a green line.
- #
- # This is a RATCHET, not a repair: the gate's first full-population run on
- # this tree is green — 12 shell files, 19 constructs, 0 findings. The card
- # that filed it had grepped 10 of the 19 by hand; this step is what makes
- # the other 9 a measurement instead of an assumption, and what stops the
- # first violation being typed.
- #
- # Copied from objectstack `scripts/check-bash32-floor.mjs`; the script's
- # own `copied from objectstack@` line names the commit it was last synced
- # at. Since objectui#10208 no gate compares it with upstream, and drift
- # from upstream is found by hand.
- #
- # Runs before install, next to the gates above and for the same
- # reason: node builtins, `scripts/invoked-as.mjs` and `git ls-files` only,
- # so nothing it needs is in `node_modules` and an install failure cannot
- # make it green. Invoked as `node` rather than through the
- # `pnpm check:bash32-floor` alias for that placement, matching the steps
- # above; `scripts/__tests__/bash32-floor-wiring.test.ts` holds the two
- # spellings to the same script. `--self-test` runs FIRST and is the half
- # that stops the scanner rotting into decoration — a pattern that matches
- # nothing reports a clean tree forever, so every row is driven against a
- # real instance of its construct AND against the 3.2 replacement the
- # failure text tells you to write.
- #
- # Cost, measured on this tree so the job ceiling question is answered
- # rather than assumed: both invocations together run in well under two
- # seconds. `timeout-minutes: 25` at the job header is DERIVED from this
- # job's own distribution (objectui#7270, max 7m14s) and is untouched.
- - name: Verify this repository's shell holds the bash 3.2 floor
- if: steps.relevant.outputs.should_run == 'true'
- run: |
- node scripts/check-bash32-floor.mjs --self-test
- node scripts/check-bash32-floor.mjs
-
- # ── Cache bookkeeping cannot void a recorded verdict (objectui#7048) ──
- # THE ORDERING, carried here from the type-check split in `ci.yml`
- # (objectui#6577, PR #7047) because it is what a future reader needs in
- # order to judge whether these steps may be touched: the verdict is
- # recorded by the checking steps; everything after them is bookkeeping,
- # and bookkeeping must never discard an answer the gate already produced.
- #
- # Why the SPLIT rather than a timeout on one step: combined
- # `actions/cache` declares `main: dist/restore/index.js` plus
- # `post: dist/save/index.js`, so its save is a step the RUNNER generates
- # at job end (`Post Turbo Cache`). No workflow syntax attaches
- # `timeout-minutes` or `continue-on-error` to a generated post step.
- # Measured once, on `ci.yml`'s type-check cache: a 1-second save took
- # 789s, ran that job into its ceiling, and the resulting `cancelled` —
- # which the merge queue cannot tell from `failure` — ejected an all-green
- # pull request (objectui#6577). `actions/cache`'s own `save-always`
- # deprecation text points at this same split.
- #
- # ⚠️ This job's ceiling is `timeout-minutes: 25`, DERIVED at the job
- # header above from this job's own run distribution (objectui#7270).
- # Until that line existed the ceiling was GitHub's 360-minute default,
- # and `Lint` is a required context on `pull_request` and `merge_group`
- # alike, so a stalled save would have held the shared serial queue for up
- # to six hours before reporting `cancelled`. The bound below is still on
- # the bookkeeping step alone, and both numbers are needed: 25 minutes is
- # the backstop, 5 minutes is what a healthy save is allowed.
- #
- # The restore half is deliberately left UNBOUNDED: a restore stall fails
- # BEFORE any verdict exists — a gate that did not run, which is honest —
- # rather than a recorded verdict discarded.
- #
- # ONE restore key, this job's own prefix (objectui#12276, the shape
- # `ci.yml`'s `type-check` job took in objectui#12222). Until then this key
- # was `turbo--` with a bare `turbo--` fallback: two other
- # workflows' build jobs (`changeset-release.yml`, `performance-budget.yml`)
- # saved under that same key, and the fallback also matched the `docs` and
- # `type-check` prefixes, so this job restored and re-saved whichever
- # job's cache came first on the ref -- build outputs, for a task whose
- # own entries hold only logs. `actions/cache` tries the run's
- # OWN ref before `main`; pull-request and queue runs save nothing now (see
- # the save step below), so on those refs this key finds nothing and the
- # lookup falls through to `main`'s lint entry.
- - name: Restore Turbo Cache
- id: turbo-cache
- if: steps.relevant.outputs.should_run == 'true'
- uses: actions/cache/restore@v6
- with:
- path: .turbo/cache
- key: turbo-${{ runner.os }}-lint-${{ github.sha }}
- restore-keys: |
- turbo-${{ runner.os }}-lint-
-
- # objectui#12957: every @objectstack/* package resolves to the build of the
- # commit `.objectstack-sha` pins, read from node_modules/.cache/objectstack, so
- # it must be there before pnpm resolves anything. Restored by the pin and built
- # only on a miss (scripts/objectstack-pin.mjs); the save is this job's last step.
- - name: Restore the pinned @objectstack build
- id: objectstack-pin
- if: steps.relevant.outputs.should_run == 'true'
- uses: actions/cache/restore@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
-
- - name: Build @objectstack at the pin (skips a restored build)
- id: objectstack-build
- if: steps.relevant.outputs.should_run == 'true'
- run: node scripts/objectstack-pin.mjs build
-
- - name: Install dependencies
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm install --frozen-lockfile
-
- # `TURBO_RUN_SUMMARY` makes turbo write its run summary to `.turbo/runs/`
- # -- per task, the hash, the cache status and the tasks it depends on. It
- # changes nothing turbo runs or caches; `Report what the turbo cache
- # replayed` below reads it (objectui#12276). Every package's `lint`
- # dependsOn `//#lint:root`, and turbo folds that task's hash into theirs,
- # so `turbo.json` gives the root task the inputs it actually lints: with
- # none it hashed every file in the repository, and no commit could replay
- # a single package's lint.
- - name: Run linter
- if: steps.relevant.outputs.should_run == 'true'
- env:
- TURBO_RUN_SUMMARY: 'true'
- run: pnpm lint
-
- # ── vi.mock override VALUE shapes (objectui#8903) ─────────────────────
- # The third property of the `vi.mock` population, and the only one of the
- # three that needs a parser. `check-vi-mock-specifiers.mjs` judges whether
- # a relative specifier resolves; `check-vi-mock-inherit.mjs` judges whether
- # the factory obtains and spreads the real module. Neither judges what the
- # override is WORTH: thirteen stubs returned `{ viewers, others }` where
- # the real hook returns `PresenceUser[]`, and putting them back on disk
- # made both of those gates print a BYTE-IDENTICAL verdict line and exit 0
- # (objectui#8083, fixed by PR #8902).
- #
- # Its two siblings live in `repo-gates.yml` (objectui#12214), in steps that
- # run before that job's install so that they see every pull request shape. This one cannot: it
- # imports `typescript` to read declared return types, and
- # `check-pre-install-import-graph.mjs` fails any pre-install gate that
- # reaches a package — measured, by injecting the import into the sibling
- # and watching that gate go from exit 0 to exit 1. So it runs here, after
- # `pnpm install`.
- #
- # The `should_run` switch above is not a blind spot for it: the ignore list
- # is Markdown plus the `content`, `docs` and `.changeset` trees, and both
- # halves of what this gate compares — the override and the declared export
- # — live in TypeScript files. `check-vi-mock-override-shape.test.ts` fails
- # if a pattern matching a TypeScript file is ever added to that list.
- - name: Check every vi.mock override matches the declared export shape
- if: steps.relevant.outputs.should_run == 'true'
- run: node scripts/check-vi-mock-override-shape.mjs
-
- # ── Test path roots (objectui#8953) ───────────────────────────────────
- # A test that resolves a repository path from `process.cwd()` reads a
- # different tree under a package's own `test` script than under the form
- # CI runs, so the same assertion reaches two verdicts. AGENTS.md has
- # taught that rule since PR objectui#8952 and nothing enforced it;
- # objectui#7799 repaired 13 instances of it in a single day.
- #
- # This step is a SECOND signal, not the gate. The gate is
- # `scripts/__tests__/check-test-path-roots.test.ts`, which runs inside
- # `Test (shard N/16)`. Those shards are not required contexts themselves
- # since objectui#9499 — the `Test` aggregator reports for all of them, and
- # it is required and subscribes `merge_group` — so the verdict still
- # blocks a queue build, through one name instead of N. This step reaches the same verdict sooner
- # and with a message aimed at the author.
- #
- # After `pnpm install`, unlike the entry-guard step above: the scan parses
- # every test file with the TypeScript compiler, because the defect is a
- # RESOLVED ROOT and not a spelling — one of objectui#7799's own 13 was
- # invisible to that card's census regex.
- - name: Verify no test resolves a repository path from the process cwd
- if: steps.relevant.outputs.should_run == 'true'
- run: node scripts/check-test-path-roots.mjs
-
-
- # ── The cross-repo closer's outcome contract (#5261) ──────────────────
- # `cross-repo-issue-closer.yml` carries ~250 lines of inline
- # github-script, and until this step existed it was code nobody had ever
- # seen run: it fires only on a merge, its conclusion is required by
- # nothing, and every one of its runs so far has been green. That last part
- # is the problem rather than the reassurance — measured over every merged
- # pull request in this repository, its close loop has had a live target
- # roughly one and a half times a day since it landed, took the same
- # `already closed -- skipping` exit every time, and left no backlink on
- # any of them. Greenly. The header of the workflow carries the figures.
- #
- # This step is the exercise: the shipped script is extracted from the YAML
- # with a real parser (never retyped) and run under doubles the way
- # actions/github-script runs it, as one AsyncFunction body. The scenarios
- # pin the target parse, the target KIND, and the outcome of every exit —
- # which of setFailed / warning / job summary fires, and which API calls
- # were made.
- #
- # Assertion 0 is the compile, and it is not theoretical: the framework's
- # copy of this workflow was taken down twice in one day by a `SyntaxError`
- # in the inline block, i.e. a script that never ran at all, on a
- # post-merge workflow whose red nothing else in CI can see.
- #
- # `--self-test` runs FIRST and is the half that stops the battery rotting
- # into decoration: it mutates the shipped script — downgrade the verdict
- # to a warning, break out of the loop instead of isolating, drop the
- # same-repo skip, collapse the already-closed branch, strip the backlink
- # marker, drop the pull-request guard, and the rest — and requires the
- # battery to go RED for each, naming the scenario that catches it. Neither
- # the mutations nor the scenarios are counted here: a hand-copied
- # enumeration drifts by construction, which is the lesson this workflow's
- # own header records. A mutation whose anchor no longer exists is a
- # failure too, so rewriting the workflow cannot leave them silently
- # matching nothing.
- #
- # Invoked as `node` rather than through a `pnpm check:*` alias, matching
- # `check-lint-coverage.mjs` above. No network, no build; well under a
- # second.
- - name: Cross-repo closer outcome contract
- if: steps.relevant.outputs.should_run == 'true'
- run: |
- node scripts/check-cross-repo-closer-outcome.mjs --self-test
- node scripts/check-cross-repo-closer-outcome.mjs
-
- # ── The product's own check command, on the product's own tree ────────
- # `objectui check` is what a consumer runs against their schema tree, and
- # the root `check` script points that same command at this repository.
- # Nothing ran it. It exited 1 with 64 errors on `main` — and had done
- # since the first `tsconfig.json` grew a comment — until someone ran it by
- # hand while measuring something unrelated (objectui#5237, fixed by
- # #5245). A shipped command sitting red on its own repository is the
- # dogfood invariant failing, and the reason it could sit there is that no
- # gate ever asked. This is that gate (objectui#5246).
- #
- # The build step below is not a convenience. The root script is
- # `node packages/cli/dist/cli.js check`, and this job installs without
- # building, so without it the step dies on a missing file. That failure
- # mode is worse than no gate at all: it is red for a reason that has
- # nothing to do with the tree being checked, and the obvious repair from
- # outside is to delete the step — leaving the hole exactly as it was, now
- # with a commit saying it was considered.
- #
- # `...` is pnpm's dependency closure: the CLI plus every workspace
- # package it depends on, in topological order, derived from the graph
- # rather than listed here. The closure is the requirement and not just
- # tidiness — `dist/cli.js` imports `@object-ui/types`' built output at
- # startup, so building the CLI package alone produces a binary that
- # cannot load (objectui#5237 records the same import as the reproduce
- # recipe's first step).
- #
- # Deliberately NOT `turbo run build`, although the Turbo cache above is
- # restored by this point and would usually make it free. A turbo cache
- # HIT restores a task's recorded outputs, and an entry recorded with an
- # empty output set replays as "cache hit, replaying logs" plus FULL
- # TURBO while writing no `dist/` at all — measured on this repo, where
- # the CLI then died with ERR_MODULE_NOT_FOUND on the types import above.
- # A blocking gate must not be able to fail for a reason that lives in a
- # cache rather than in the tree it is judging, which is the same argument
- # as the paragraph above one level down. Building through pnpm has no
- # cache layer to replay, and it costs well under a minute.
- - name: Build the CLI the self-check runs
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm --filter '@object-ui/cli...' build
-
- # Errors only, which is the command's existing behaviour rather than a
- # setting chosen here: a parse failure increments the error count, a
- # non-zero count is the only thing that exits 1, and the unknown-schema-
- # type arm prints and moves on. So this step blocks on the same arm
- # `pnpm lint` above does, and for the same reason — errors are a signal,
- # the warning stream is known debt. Nothing here promotes those warnings
- # to failures, and no output-suppressing flag hides them either: they stay
- # visible in the log and non-blocking. That arm belongs to objectui#5127,
- # which is open; whatever it settles changes what this step PRINTS, never
- # what it fails on. No count is quoted, for the reason the `--max-warnings`
- # paragraph in the header gives.
- - name: Verify the CLI's own check command passes on this repository
- if: steps.relevant.outputs.should_run == 'true'
- run: pnpm check
-
- # ── What the cache replayed (objectui#12276) ─────────────────────────
- # The `type-check` job's report step (objectui#12222), here for this job's
- # cache. Bookkeeping, like the save below: it runs after the last checking
- # step, so it can neither delay nor change the verdict. It reads the run
- # summary `Run linter` asked turbo for and prints, per task, what the
- # restored cache replayed and what re-ran -- each re-run task with the
- # tasks it depends on, so a dependency whose hash moved every package's
- # is named in the log -- and puts the counts and the restored key on ONE
- # `::notice` annotation, which the check-runs API serves to readers who
- # cannot open this log (AGENTS.md #9: the instrument is the claim, its
- # answer is not written down). `!cancelled()` so a red lint still
- # reports what it replayed.
- #
- # `timeout-minutes: 1` is DERIVED FOR THIS SITE: the script reads one
- # summary and lists one directory, and measured 87 ms on a lint run's
- # summary (objectui#12276); a minute is several hundred times that, and
- # with the save's 5 below this job's bookkeeping stays at 7 of its 25.
- # `continue-on-error: true` because a broken report says nothing about
- # the code under lint.
- - name: Report what the turbo cache replayed
- if: steps.relevant.outputs.should_run == 'true' && !cancelled()
- timeout-minutes: 1
- continue-on-error: true
- env:
- RESTORED_CACHE_KEY: ${{ steps.turbo-cache.outputs.cache-matched-key }}
- run: node scripts/report-turbo-cache.mjs
-
- # ── Only `push` writes this cache, and only what it used ─────────────
- # The `type-check` job's rule (objectui#12222): pull-request and queue
- # runs restore and never save, because what they would save is scoped to
- # a ref no later run of another pull request can read, and every entry
- # saved is one more a size-limited cache store can evict `main`'s for.
- # A `push` run saves, after this step deletes every entry the run did
- # not use, so `main`'s entry is exactly the current lint verdicts.
- #
- # `push` alone, not `push` or `schedule`: this workflow has no
- # `schedule`. Measured on objectui#12276 before relying on it: the push
- # concurrency group here is the ref, so a merge can cancel the previous
- # merge's run, and 14 of the 20 newest `main` push runs succeeded (6
- # cancelled) -- the save lands on most merges, and a pull request
- # restores the newest one that did. Bookkeeping like the report step:
- # bounded, `continue-on-error`; `scripts/report-turbo-cache.mjs` refuses
- # to prune on a summary that lists no task.
- - name: Prune the turbo cache to what this run used
- if: >-
- steps.relevant.outputs.should_run == 'true'
- && github.event_name == 'push'
- timeout-minutes: 1
- continue-on-error: true
- run: node scripts/report-turbo-cache.mjs --prune
-
- # The bookkeeping half of the split documented at the restore step above,
- # placed HERE — after the last checking step — because that is exactly
- # where the post phase it replaces already ran. Nothing about which
- # commits this job accepts or rejects moves with it.
- #
- # `timeout-minutes: 5` is DERIVED FOR THIS SITE, and landing on the same
- # number PR #7047 chose is a result rather than a copy — objectui#7048
- # fences inheriting that 5, so here is this site's own arithmetic. This
- # job's own save of this cache measured 6s on 2026-09-02 (`Post Turbo
- # Cache`, job 100097466846); the whole job was 5m14s. 5 minutes is 50x
- # the measured save, so it cannot fire on a working runner, and it is a
- # bound on a step whose only alternative backstop is a 360-minute default
- # ceiling.
- #
- # `continue-on-error: true` is the other half, and without it the bound
- # would only trade a `cancelled` gate for a red one. A cache that failed
- # to upload costs the next run some time; it says nothing whatsoever
- # about the code under test, so it must not be allowed to speak for it.
- #
- # Behaviour preserved, spelled out so the equivalence is checkable:
- # - `cache-hit != 'true'` reproduces the combined action's own "exact
- # hit on the primary key ⇒ do not save" skip. A `restore-keys` prefix
- # match leaves `cache-hit` false and the save still runs, exactly as
- # the combined action behaved.
- # - the condition names no status function, so the implicit `success()`
- # still applies — matching the combined action's `post-if: success()`.
- # - same `path` and same `key` as the restore step above.
- # One deliberate change (objectui#12276): only `push` saves, after the
- # prune step above; see there for why.
- - name: Save Turbo Cache
- if: >-
- steps.relevant.outputs.should_run == 'true'
- && steps.turbo-cache.outputs.cache-hit != 'true'
- && github.event_name == 'push'
- uses: actions/cache/save@v6
- timeout-minutes: 5
- continue-on-error: true
- with:
- path: .turbo/cache
- key: turbo-${{ runner.os }}-lint-${{ github.sha }}
-
- # The pinned @objectstack build, saved only when this job built it (a cache
- # miss) and whatever the verdict above was -- it does not depend on it.
- # Bookkeeping: bounded and non-fatal (objectui#6577, objectui#7048). The bound
- # is not yet derived from a measured save; re-derive it from this step's runs.
- - name: Save the pinned @objectstack build
- if: ${{ !cancelled() && steps.relevant.outputs.should_run == 'true' && steps.objectstack-build.outcome == 'success' && steps.objectstack-pin.outputs.cache-hit != 'true' }}
- continue-on-error: true
- timeout-minutes: 2
- uses: actions/cache/save@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
diff --git a/ui/.github/workflows/live-e2e.yml b/ui/.github/workflows/live-e2e.yml
deleted file mode 100644
index ef3558f66a6..00000000000
--- a/ui/.github/workflows/live-e2e.yml
+++ /dev/null
@@ -1,376 +0,0 @@
-# Live E2E — the console driven against a REAL ObjectStack backend (#2835).
-#
-# What runs: an allowlist of e2e/live specs (the list is whatever the
-# `test:e2e:live:ci` script in package.json names — that script is the single
-# source of truth; do not re-enumerate the specs here, a hand-copied list
-# drifts on every promotion) against `objectstack dev` booted from PUBLISHED
-# @objectstack/* packages serving the showcase app. Every PR therefore
-# smoke-tests "this console x the released backend" — the class of bug only
-# a real browser against a real backend can catch (framework#3528: a
-# lazily-loaded widget's suspension unwound to the host's route boundary and
-# tore down the flow dialog; zero unit test could see it).
-#
-# ⚠️ INFORMATIONAL, NON-REQUIRED lane. What keeps it non-blocking is what it
-# is NOT wired into, and ⛔ never a `continue-on-error:` on the job:
-# - it is not in the required-check set — ⛔ do not add this job to required
-# checks until the nightly record proves the lane stable; and
-# - it declares no `merge_group` trigger, so a queue build never waits on it
-# and it cannot eject an unrelated pull request from the queue.
-# (objectstack#4850 is the prior art for why a new lane must prove itself
-# outside the merge gate first.)
-#
-# ⛔ Do NOT re-add `continue-on-error: true` to this job (objectui#8084). It
-# bought none of the non-blocking above, and it cost this lane the one reading
-# a monitor, a digest or an agent takes cheaply. Measured on the 2026-09-06
-# nightly, while the job still carried the flag:
-#
-# step 7 `Start ObjectStack backend` failure
-# job 101442890465 failure
-# check run `Live E2E (informational)` failure <- the flag did not move it
-# check suite 92170955546 success <- asserts the negation
-# workflow run 34017174769 success <- asserts the negation
-#
-# The flag moved neither the job nor its check run — the two readings a merge
-# gate and a reviewer actually consult — and inverted only the aggregate. So an
-# outage detector reported `success` for a day while the E2E had not executed
-# at all, which is how the backend outage carded as objectui#8084's defect ①
-# ran unseen. Without the flag the run conclusion simply agrees with the job it
-# contains, and the lane stays exactly as advisory as it was.
-#
-# Failures surface as a red step, a red job, a red run, a job summary and the
-# `live-e2e-artifacts` upload. That upload carries `test-results/` (the
-# failure screenshots and videos `use.screenshot` / `use.video` write) and the
-# two server logs — it does NOT carry a Playwright HTML report, because
-# `playwright.live.config.ts` declares `reporter: [['list']]` and the `list`
-# reporter writes to stdout only (objectui#8238). The durable record of what
-# the specs actually did is the `Run live E2E allowlist` step's own log.
-#
-# Growing the allowlist: add specs to `test:e2e:live:ci` in package.json a
-# few at a time, only after they prove flake-free here — do not switch all
-# 20+ live specs on at once and inherit whatever flake exists (#2835's
-# sequencing note).
-#
-# Backend pins live in e2e/live/ci/backend.env — the published package
-# version MUST match the @objectstack/spec version in pnpm-lock.yaml (a
-# mismatched pair proves nothing; bump both in the same PR).
-name: Live E2E
-
-on:
- pull_request:
- branches: [main, develop]
- paths-ignore:
- - '**/*.md'
- - 'content/**'
- - 'docs/**'
- - 'apps/site/**'
- - '.changeset/**'
- # Nightly on main: accumulates the stability record that decides when the
- # lane may be promoted to a required check.
- schedule:
- - cron: '30 6 * * *'
- workflow_dispatch:
-
-concurrency:
- group: live-e2e-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
-
-permissions:
- contents: read
-
-jobs:
- live-e2e:
- name: Live E2E (informational)
- runs-on: ubuntu-latest
- timeout-minutes: 40
- # ⛔ There is deliberately no `continue-on-error:` here, and ⛔ nothing may
- # add one back: it never made this job or its check run any less red, it
- # only made the workflow-run conclusion contradict them (objectui#8084).
- # The header carries the measurement.
-
- # ⚠️ Do NOT add a `runner.*` expression to this job-level `env:` block.
- # The `runner` context does not exist yet when job-level env is evaluated
- # (GitHub's "contexts availability" table allows only github / needs /
- # strategy / matrix / vars / secrets / inputs here). One `${{ runner.temp }}`
- # in this block fails the whole FILE at validation time — 0 jobs, 0s,
- # "startup failure" — and `continue-on-error` cannot soften it because no
- # job is ever created. Need a runner path? Use the `$RUNNER_TEMP`
- # environment variable inside a `run:` step, or a step-level `env:` /
- # `with:` (both may read `runner`). See the LIVE_BACKEND_DIR step below.
- env:
- LIVE_BACKEND_PORT: '4010'
- LIVE_API_URL: http://localhost:4010
- LIVE_APP_URL: http://localhost:5190
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v7
- with:
- submodules: true
-
- - name: Enable Corepack and download the pinned pnpm
- run: bash scripts/ci-setup-pnpm.sh
-
- - name: Setup Node.js
- uses: actions/setup-node@v7
- with:
- node-version: '22.x'
- cache: 'pnpm'
-
- # objectui#12957: every @objectstack/* package resolves to the build of the
- # commit `.objectstack-sha` pins, read from node_modules/.cache/objectstack, so
- # it must be there before pnpm resolves anything. Restored by the pin and built
- # only on a miss (scripts/objectstack-pin.mjs); the save is this job's last step.
- - name: Restore the pinned @objectstack build
- id: objectstack-pin
- uses: actions/cache/restore@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
-
- - name: Build @objectstack at the pin (skips a restored build)
- id: objectstack-build
- run: node scripts/objectstack-pin.mjs build
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- # The backend fixture (showcase metadata + published @objectstack/*
- # node_modules) only changes when the pins do — cache it on the pin file.
- # start-backend.sh's stamp check makes a cache hit skip clone + install.
- #
- # ── Cache bookkeeping cannot void a recorded verdict (objectui#7048) ──
- # THE ORDERING, carried here from the type-check split in `ci.yml`
- # (objectui#6577, PR #7047) because it is what a future reader needs in
- # order to judge whether these steps may be touched: the verdict is
- # recorded by the checking steps; everything after them is bookkeeping,
- # and bookkeeping must never discard an answer the gate already produced.
- #
- # Why the SPLIT rather than a timeout on one step: combined
- # `actions/cache` declares `main: dist/restore/index.js` plus
- # `post: dist/save/index.js`, so its save is a step the RUNNER generates
- # at job end (`Post Cache live backend fixture`). No workflow syntax
- # attaches `timeout-minutes` or `continue-on-error` to a generated post
- # step, so an upload stall runs this job into `timeout-minutes: 40` and
- # it reports `cancelled` — a lane that says nothing at all, which for an
- # informational lane whose only product is a stability record is the
- # worst outcome available. Measured once, on `ci.yml`'s type-check cache:
- # a 1-second save took 789s (objectui#6577). `actions/cache`'s own
- # `save-always` deprecation text points at this same split.
- #
- # ⛔ Raising `timeout-minutes: 40` is the ruled-out non-fix: a larger
- # ceiling only buys a longer hang and still ends in `cancelled`.
- #
- # This job has TWO caches, so it gets two bounds. Together they can
- # consume at most 5 + 8 = 13 minutes of the 40-minute ceiling, against a
- # verdict path that measured 2m25s end to end (job 100097466897) — so
- # even both bookkeeping halves stalling at once cannot reach the ceiling.
- #
- # The restore halves are deliberately left UNBOUNDED: a restore stall
- # fails BEFORE any verdict exists — a lane that did not run, which is
- # honest — rather than a recorded verdict discarded.
- - name: Restore live backend fixture
- id: live-backend-cache
- uses: actions/cache/restore@v6
- with:
- path: ${{ runner.temp }}/live-backend
- key: live-backend-${{ runner.os }}-${{ hashFiles('e2e/live/ci/backend.env') }}
-
- - name: Start ObjectStack backend (published packages)
- # Step-level env — `runner` IS available here (unlike job-level env).
- # Must resolve to the same path as the cache step's `path:` above and
- # the artifact upload's `path:` below, or the cache silently stops
- # hitting; keep the three in sync.
- env:
- LIVE_BACKEND_DIR: ${{ runner.temp }}/live-backend
- run: bash e2e/live/ci/start-backend.sh
-
- - name: Build console
- # Absolute base so `vite preview`'s SPA fallback serves deep links
- # (/apps/showcase_app/…) with resolvable asset URLs — the default
- # relative base ('./') 404s them (same reason ci.yml's e2e job pins
- # VITE_BASE_PATH). Vite, not the package build: the SPA bundle is the
- # only artifact the tests consume.
- env:
- VITE_BASE_PATH: /
- run: pnpm --filter @object-ui/console exec vite build
-
- - name: Serve console (vite preview)
- # `preview.proxy` defaults to `server.proxy`, so /api is proxied to
- # DEV_PROXY_TARGET just like the dev server would.
- working-directory: apps/console
- run: |
- DEV_PROXY_TARGET="$LIVE_API_URL" nohup pnpm exec vite preview --port 5190 --strictPort \
- > "$RUNNER_TEMP/console-preview.log" 2>&1 &
- echo $! > "$RUNNER_TEMP/console-preview.pid"
- for i in $(seq 1 60); do
- curl -sf -o /dev/null "$LIVE_APP_URL" && exit 0
- sleep 1
- done
- echo "console preview never became ready" >&2
- tail -50 "$RUNNER_TEMP/console-preview.log" >&2
- exit 1
-
- - name: Get Playwright version
- id: playwright-version
- run: |
- # This pipeline's exit status is load-bearing: the value it produces
- # becomes the Playwright browser cache key in the next step. Without
- # `pipefail` a failing `pnpm list` is masked by `jq`, and `jq -r`
- # prints the string `null` and exits 0 when the field is missing — so
- # either failure used to yield a *successful* step and a key that had
- # silently degraded to `playwright-Linux-` / `playwright-Linux-null`.
- # That wrong bucket is stable, so two Playwright versions can share
- # one cache entry and restore a stale browser (objectui#6231).
- # The same block is duplicated verbatim in `ci.yml` and `live-e2e.yml`
- # — keep them byte-identical so they stay greppable as a pair.
- set -eo pipefail
- if ! version=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version'); then
- echo "::error::Reading the @playwright/test version failed (pnpm list --json | jq). Refusing to write a Playwright browser cache key from it."
- exit 1
- fi
- if [ -z "$version" ] || [ "$version" = "null" ]; then
- echo "::error::Could not resolve the @playwright/test version (got: '${version}'). Refusing to write an empty or null version into the Playwright browser cache key."
- exit 1
- fi
- echo "version=$version" >> "$GITHUB_OUTPUT"
-
- # The second of this job's two caches; the split, the ordering sentence
- # and the ruled-out non-fix are all documented at `Restore live backend
- # fixture` above (objectui#7048). Same shape here: restore in place and
- # unbounded, save last and bounded.
- - name: Restore Playwright browsers
- uses: actions/cache/restore@v6
- id: playwright-cache
- with:
- path: ~/.cache/ms-playwright
- key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
-
- # Same provisioning shape as `ci.yml`'s `Build & E2E`, and it carries the
- # same exposure: `--with-deps` / `install-deps` run `apt-get update` with
- # nothing bounding it. That is what hung `Build & E2E` three times on
- # 2026-08-19 for the full job ceiling (objectui#5304); this job's ceiling
- # is 40 minutes, so the same mirror stall would cost even more here. The
- # browsers come from the cache step above, and the launch probe in the
- # script is what proves a browser exists. See the script header for the
- # measured incident log.
- - name: Install Playwright browsers
- if: steps.playwright-cache.outputs.cache-hit != 'true'
- run: pnpm exec playwright install chromium
-
- - name: Ensure Chromium can launch
- run: bash scripts/ensure-chromium-ready.sh
-
- - name: Run live E2E allowlist
- run: pnpm test:e2e:live:ci
-
- - name: Report failure in job summary
- if: failure()
- run: |
- {
- echo "## Live E2E failed (informational lane — does not block merge)"
- echo ""
- echo "Backend: published \`@objectstack/*\` per \`e2e/live/ci/backend.env\`."
- echo "What the specs did is in this job's \`Run live E2E allowlist\` step log (the \`list\` reporter writes to stdout; there is no HTML report to open)."
- echo "See the \`live-e2e-artifacts\` upload for the failure screenshots and videos (\`test-results/\`) and the two server logs."
- echo "If this failure reproduces on re-run it is a real console x backend integration bug — treat it as such even though the lane cannot block your merge."
- } >> "$GITHUB_STEP_SUMMARY"
-
- # ⛔ Do not add `playwright-report/` back to this glob without changing
- # `playwright.live.config.ts` in the same PR. That config declares
- # `reporter: [['list']]`; the `list` reporter writes to stdout and no
- # reporter in it writes `playwright-report/`, so the path matched nothing
- # in EVERY outcome — measured on a passing and on a failing run, absent
- # both times — while this workflow's header and
- # `content/docs/guide/ci-cd-pipeline.md` promised readers a report they
- # could open (objectui#8238). The reporter list is the one line that
- # decides it, and the claim is pinned in
- # `scripts/__tests__/ci-cd-pipeline-doc.test.ts`.
- #
- # ⚠️ This step is gated on `failure()`, so on a GREEN lane it does not run
- # and there is NO artifact at all. Any acceptance criterion phrased over
- # this artifact's contents is therefore readable only on a run that
- # failed; a green run leaves nothing to count (objectui#8238).
- - name: Upload failure artefacts and server logs
- uses: actions/upload-artifact@v7
- if: ${{ !cancelled() && failure() }}
- with:
- name: live-e2e-artifacts
- path: |
- test-results/
- ${{ runner.temp }}/live-backend/backend.log
- ${{ runner.temp }}/console-preview.log
- retention-days: 14
-
- # The bookkeeping halves of the two splits documented at the restore
- # steps above, placed HERE — last in the job — because that is exactly
- # where the post phases they replace already ran. They are ordered
- # Playwright-then-fixture to preserve the observed post-phase order
- # (post steps run in reverse registration order, so `Post Cache
- # Playwright browsers` ran before `Post Cache live backend fixture` —
- # job 100097466897). Nothing about what this lane reports moves with them.
- #
- # `timeout-minutes: 8` on the browser cache is DERIVED FOR THIS CACHE,
- # not inherited from the type-check site's 5 (objectui#7048 fences that
- # explicitly): it is Playwright's browser binaries at ~269 MB. Restores
- # of that exact archive measured 5s here (job 100097466897) and 3s in
- # `ci.yml`'s `Build & E2E` (jobs 100096569775, 100094597323) ⇒ ~54-90
- # MB/s in the download direction; a save is only observable after a
- # version bump (an exact key hit skips it, and `Post Cache Playwright
- # browsers` measured 0s on every sampled run), so the honest save is
- # bounded from the restore rather than read directly — said plainly
- # rather than dressed up as a direct measurement. A deliberately
- # pessimistic 20x penalty on the slowest observed restore gives ~1m40s;
- # 8 minutes is ~4.8x that and demands only 0.56 MB/s sustained.
- #
- # `timeout-minutes: 5` on the fixture cache is derived from THIS site's
- # own measurement: its save measured 8s on 2026-09-02 (`Post Cache live
- # backend fixture`, job 100097466897 — a real save, not a skip: the
- # restore missed, which is why `Start ObjectStack backend` did the full
- # clone + install). 5 minutes is ~37x that.
- #
- # `continue-on-error: true` on both is the other half, and without it the
- # bounds would only trade a `cancelled` lane for a red one. A cache that
- # failed to upload costs the next run some time; it says nothing about
- # the console-x-backend integration this lane exists to observe.
- #
- # Behaviour preserved, spelled out so the equivalence is checkable:
- # - `cache-hit != 'true'` reproduces the combined action's own "exact
- # hit on the primary key ⇒ do not save" skip.
- # - neither condition names a status function, so the implicit
- # `success()` still applies — matching `post-if: success()`. In
- # particular a failed `Run live E2E allowlist` skips both saves, just
- # as the post phases skipped.
- # - same `path` and same `key` as the matching restore step.
- - name: Save Playwright browsers
- if: steps.playwright-cache.outputs.cache-hit != 'true'
- uses: actions/cache/save@v6
- timeout-minutes: 8
- continue-on-error: true
- with:
- path: ~/.cache/ms-playwright
- key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
-
- - name: Save live backend fixture
- if: steps.live-backend-cache.outputs.cache-hit != 'true'
- uses: actions/cache/save@v6
- timeout-minutes: 5
- continue-on-error: true
- with:
- path: ${{ runner.temp }}/live-backend
- key: live-backend-${{ runner.os }}-${{ hashFiles('e2e/live/ci/backend.env') }}
-
- # Ephemeral runner — no teardown needed; stop-backend.sh exists for
- # local runs of the same scripts.
-
- # The pinned @objectstack build, saved only when this job built it (a cache
- # miss) and whatever the verdict above was -- it does not depend on it.
- # Bookkeeping: bounded and non-fatal (objectui#6577, objectui#7048). The bound
- # is not yet derived from a measured save; re-derive it from this step's runs.
- - name: Save the pinned @objectstack build
- if: ${{ !cancelled() && steps.objectstack-build.outcome == 'success' && steps.objectstack-pin.outputs.cache-hit != 'true' }}
- continue-on-error: true
- timeout-minutes: 2
- uses: actions/cache/save@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
diff --git a/ui/.github/workflows/lockfile-dedupe.yml b/ui/.github/workflows/lockfile-dedupe.yml
deleted file mode 100644
index d42da0280e6..00000000000
--- a/ui/.github/workflows/lockfile-dedupe.yml
+++ /dev/null
@@ -1,207 +0,0 @@
-name: Lockfile Dedupe
-
-# objectui#8333 — the committed `pnpm-lock.yaml` must already be DEDUPED, i.e.
-# `pnpm dedupe` on it may not collapse anything. The mechanism, what it catches
-# and what its green does NOT mean are documented at length in
-# `scripts/check-lockfile-dedupe.mjs`.
-#
-# ── Why this exists next to `lockfile-integrity.yml` rather than inside it ────
-#
-# They answer different questions. objectui#8326's gate reports a DELTA against
-# the merge base — "did THIS change duplicate something?" — and needs two
-# revisions of one file to do it. This one reports a PROPERTY of a single tree —
-# "would `pnpm dedupe` still have work to do here?" — and needs no base at all,
-# but does need pnpm and the registry. Different subject, different cost,
-# different failure modes, so they are separate jobs and each stays readable.
-#
-# ── Why it is path-filtered ──────────────────────────────────────────────────
-#
-# Its subject is one file. A pull request that does not touch `pnpm-lock.yaml`
-# cannot change whether that lockfile is deduped, so the filter costs no
-# coverage. A manifest edit that WOULD change the resolution cannot reach `main`
-# without moving the lockfile either, because `ci.yml` installs with
-# `--frozen-lockfile` and fails first. Same argument shape as its neighbour.
-#
-# The filter also lists this gate's RUNTIME CLOSURE — the files the job actually
-# executes — which is this repository's convention for a path-filtered gate
-# (`performance-budget.yml` states it at length): without that, a change to the
-# checker itself ships having never run once, and the wiring bug then surfaces on
-# somebody else's next lockfile PR, reading to them as a problem of their own.
-#
-# ⚠️ The filter is also what makes it UNREQUIRABLE: objectui#3523's rule,
-# enforced by `scripts/__tests__/dependabot-merge-gate.test.ts`, is that a
-# REQUIRED context must come from a workflow whose `pull_request` trigger has no
-# path filter. It is enrolled as an OPTIONAL context instead — present it must
-# be green, absent it is not waited for. `Bundle Analysis` is enrolled on
-# exactly those terms and its filter also includes `pnpm-lock.yaml`.
-#
-# ── ⚠️ Its VERDICT no longer blocks a pull request — a ruling, not a default ──
-#
-# objectui#9562 measured this gate returning FOUR GREEN AND ONE RED on a
-# byte-identical `pnpm-lock.yaml` inside ninety minutes: same blob at every ref,
-# no lockfile edit anywhere, and no registry publish that day to explain it. The
-# red printed a confident `VERDICT not deduped` naming an `esbuild` peer split —
-# and then told the reader to fix it HERE by committing a dedupe, to a file every
-# open pull request shares. ⛔ The mechanism was never identified, and this file
-# ⛔ does not adopt one.
-#
-# The maintainer ruled letter A on 2026-09-17 (card objectui#9562, ruling comment
-# 5717182406): a gate that is non-deterministic BY CONSTRUCTION does not block.
-# So the job now passes `--report-only` to the checker below. What that changes,
-# and ⛔ what it deliberately does not:
-#
-# * CHANGED — on a pull request a `not deduped` verdict is a `::warning::`
-# annotation plus a step-summary block naming the split, and the step exits
-# 0. The finding text, in BOTH modes, now says what the instrument is:
-# re-run before acting, and dedupe only when the split reproduces.
-# * UNCHANGED — the job keeps its name (`Lockfile Dedupe Check`), its path
-# filter and its `OPTIONAL_CONTEXTS` classification, so ⛔ nothing in branch
-# protection or the merge queue moves. The BARE script keeps its 0/1/2 exit
-# codes for whoever runs it by hand; that is the way to ask for the hard
-# verdict, and ⛔ it is not an oversight to tidy away.
-# * ⚠️ STILL HAS TEETH, and this is why the classification stays honest rather
-# than becoming vestigial: `--report-only` relaxes the VERDICT, not the JOB.
-# A red checkout, a failed `ci-setup-pnpm.sh`, or a `--self-test` that stops
-# passing still fails this context and still stops a Dependabot auto-merge.
-# What no longer reds is a live registry reading disagreeing with itself.
-#
-# ⚠️ `scripts/dependabot-merge-gate.mjs`'s entry for this name still reads
-# "Blocking when it runs". After this change that is imprecise for the verdict
-# (true for the job, per the bullet above); the classification itself is correct
-# and ⛔ was deliberately not edited here — that file is held by another open
-# pull request, and prose is not worth the collision.
-#
-# ⇒ what this gate still defends is objectui#9215's paydown: on `main` after that
-# collapse it is green, on `main` before it the same command reds. It now defends
-# it by REPORTING the drift rather than by refusing the pull request.
-#
-# ── No `merge_group` trigger ─────────────────────────────────────────────────
-#
-# Deliberate, and the same shape as `lockfile-integrity.yml` and
-# `performance-budget.yml`: under objectui#3523 a required context that does not
-# report on a queue build stalls the queue until the ruleset timeout fails it, so
-# a path-filtered, non-required gate must not subscribe one.
-
-on:
- pull_request:
- branches: [main, develop]
- paths:
- - 'pnpm-lock.yaml'
- # This gate's runtime closure — the files this job executes. An edit to any
- # of them must be able to red this gate on its OWN pull request.
- - '.github/workflows/lockfile-dedupe.yml'
- - 'scripts/check-lockfile-dedupe.mjs'
- # `isEntrypoint` decides whether `main()` runs at all, so a regression in it
- # makes this checker exit 0 having measured nothing — the exact silent-green
- # failure the rest of this gate exists to prevent. Same reason
- # `performance-budget.yml` and `half-state-patrol.yml` list it.
- - 'scripts/invoked-as.mjs'
- # The toolchain step this job runs before pnpm is on PATH.
- - 'scripts/ci-setup-pnpm.sh'
- # The pinned @objectstack/* build this job restores or builds before the
- # check, and which the checker imports for its path (objectui#12957).
- - 'scripts/objectstack-pin.mjs'
- workflow_dispatch:
-
-concurrency:
- group: lockfile-dedupe-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
-
-permissions:
- contents: read
-
-jobs:
- lockfile-dedupe:
- name: Lockfile Dedupe Check
- runs-on: ubuntu-latest
-
- # ── The job ceiling, DERIVED FOR THIS JOB ────────────────────────────────
- #
- # ⛔ NOT inherited from `lockfile-integrity.yml`'s 5 or `ci.yml`'s ladder —
- # objectui#7048 fences exactly that. The arithmetic below is this job's own,
- # and its weakness is stated rather than hidden.
- #
- # - Population: `pnpm dedupe --check` on a clean `origin/main` worktree,
- # n=3 consecutive runs, pnpm 10.31.0 / Node 22.22.2, WARM pnpm metadata
- # cache, on a developer box and not a runner: 22.8s / 22.9s / 23.2s, all
- # exit 0. Spread 0.4s, so the command itself is not variable.
- # - ⚠️ That population is the wrong one for a ceiling and is known to be:
- # this workflow has no run history yet, and a runner starts with a COLD
- # metadata cache, which is the dominant term. So the max is not measured
- # but BOUNDED, pessimistically, at 5 minutes for checkout + Node + the
- # Corepack fetch + a cold resolve of ~1,767 identities.
- #
- # Ceiling = the smallest round number that is both >= 3x that bound (15min)
- # and >= bound + 15min (20min); the second leg binds => 20.
- #
- # ⚠️ Re-derive this from real runs once this workflow has a history — the
- # instrument is the Actions jobs endpoint (`completed_at` minus
- # `started_at`, per job, never the run total), the same one
- # `performance-budget.yml` names. ⛔ Do not copy the number above forward as
- # if it were a measurement of this job in CI; it is a bound on one.
- timeout-minutes: 20
-
- steps:
- # No `fetch-depth: 0` here, unlike its neighbour: this gate's subject is a
- # single tree, not a delta, so it needs no merge base and no history.
- - name: Checkout code
- uses: actions/checkout@v7
-
- - name: Setup Node.js
- uses: actions/setup-node@v7
- with:
- node-version: '22.x'
- # No `cache: 'pnpm'` — that action's store probe shells out to pnpm
- # before the step below has put it on PATH (objectui#8099 documents
- # that exact ordering trap), and this job runs no `pnpm install`, so
- # there is no node_modules for a cache to pay for.
-
- - name: Enable Corepack and download the pinned pnpm
- run: bash scripts/ci-setup-pnpm.sh
-
- - name: Verify pnpm version
- run: pnpm --version
-
- # The checker's own cases, before the checker is trusted to judge anything.
- - name: Self-test the checker
- run: node scripts/check-lockfile-dedupe.mjs --self-test
-
- # objectui#12957: every @objectstack/* package resolves to the build of the
- # commit `.objectstack-sha` pins, read from node_modules/.cache/objectstack, so
- # it must be there before pnpm resolves anything. Restored by the pin and built
- # only on a miss (scripts/objectstack-pin.mjs); the save is this job's last step.
- - name: Restore the pinned @objectstack build
- id: objectstack-pin
- uses: actions/cache/restore@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
-
- - name: Build @objectstack at the pin (skips a restored build)
- id: objectstack-build
- run: node scripts/objectstack-pin.mjs build
-
- # No `pnpm install`: `pnpm dedupe --check` resolves and reports without
- # writing a lockfile or a node_modules tree (verified on objectui#8333 —
- # the lockfile's sha256 is unchanged across repeated runs).
- # `--report-only` (objectui#9562, ruling A): the reading is unchanged, the
- # consequence is not — `::warning::` + a step summary, exit 0. ⛔ Do not
- # drop the flag to "make the gate strict again": the gate is a live
- # registry reading that was measured disagreeing with itself, and a
- # blocking version of it teaches every seat to re-run on red, which hides
- # the real failures of this same check.
- - name: Check the committed lockfile is deduped
- run: node scripts/check-lockfile-dedupe.mjs --report-only
-
- # The pinned @objectstack build, saved only when this job built it (a cache
- # miss) and whatever the verdict above was -- it does not depend on it.
- # Bookkeeping: bounded and non-fatal (objectui#6577, objectui#7048). The bound
- # is not yet derived from a measured save; re-derive it from this step's runs.
- - name: Save the pinned @objectstack build
- if: ${{ !cancelled() && steps.objectstack-build.outcome == 'success' && steps.objectstack-pin.outputs.cache-hit != 'true' }}
- continue-on-error: true
- timeout-minutes: 2
- uses: actions/cache/save@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
diff --git a/ui/.github/workflows/lockfile-integrity.yml b/ui/.github/workflows/lockfile-integrity.yml
deleted file mode 100644
index 5852643e346..00000000000
--- a/ui/.github/workflows/lockfile-integrity.yml
+++ /dev/null
@@ -1,91 +0,0 @@
-name: Lockfile Integrity
-
-# objectui#8326 — a pull request's `pnpm-lock.yaml` may not silently DUPLICATE a
-# dependency the workspace declares. (Its second rule, an `@objectstack/*`
-# identity moving BACKWARD, was retired by objectui#12957: `@objectstack/*` now
-# resolves to the build `.objectstack-sha` pins, with no version in the lockfile
-# to move.) The mechanism, both incident causes and every boundary this gate
-# keeps are documented at length in `scripts/check-lockfile-integrity.mjs`.
-#
-# ── Why it is its own workflow ───────────────────────────────────────────────
-#
-# It is the only gate in this repository that needs TWO revisions of a file: the
-# pull request's lockfile and its merge base's. That is why it checks out with
-# `fetch-depth: 0` and fetches the base branch, which no other gate here pays
-# for. Keeping it separate keeps that cost off every other job.
-#
-# ── Why it IS path-filtered, unlike the gates in repo-gates.yml ─────────────
-#
-# Those gates read the whole tree, so any change can trip them and a path filter
-# would build the hole they exist to close. This one reads exactly one file and
-# compares it with its own base: a pull request that does not touch
-# `pnpm-lock.yaml` cannot produce a finding, by construction — the head and base
-# blobs are then the same bytes. So the filter costs no coverage.
-#
-# ⚠️ The filter is also what makes it UNREQUIRABLE: `#3523`'s rule, enforced by
-# `scripts/__tests__/dependabot-merge-gate.test.ts`, is that a required context
-# must come from a workflow whose `pull_request` trigger has no path filter.
-#
-# ── ⛔ Deliberately NOT enrolled as a blocking context ────────────────────────
-#
-# `scripts/dependabot-merge-gate.mjs` classifies `Lockfile Integrity Check` in
-# `NOT_A_GATE`, so a red here does not stop a Dependabot auto-merge and does not
-# block the merge queue. That is a MAINTAINER decision with its own cost, and
-# objectui#8326's dispatch reserved it: what enrolling would take, and what it
-# would have cost over the last 40 lockfile-changing commits on `main`, is
-# written up in that card's pull request as input. ⛔ Do not promote this to
-# `REQUIRED_CONTEXTS` or `OPTIONAL_CONTEXTS` without that decision.
-#
-# Until then it is an ALARM: it names the packages, in one line, on the pull
-# request where the change is. That alone is the whole of what the incident
-# needed — two seats each spent a full diagnosis discovering that a
-# `Bundle Analysis` red was not about the dependency being bumped.
-
-on:
- pull_request:
- branches: [main, develop]
- paths:
- - 'pnpm-lock.yaml'
- - 'scripts/check-lockfile-integrity.mjs'
- - '.github/workflows/lockfile-integrity.yml'
- workflow_dispatch:
-
-concurrency:
- group: lockfile-integrity-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
-
-permissions:
- contents: read
-
-jobs:
- lockfile-integrity:
- name: Lockfile Integrity Check
- runs-on: ubuntu-latest
- timeout-minutes: 5
-
- steps:
- # `fetch-depth: 0` because the gate's subject is a DELTA. A shallow
- # checkout has no merge base, and the script refuses to guess one — it
- # exits 2 (could not take a reading), which is never a pass.
- - name: Checkout code
- uses: actions/checkout@v7
- with:
- fetch-depth: 0
-
- - name: Setup Node.js
- uses: actions/setup-node@v7
- with:
- node-version: '22.x'
-
- # No install and no build: the gate is one `node` call over two text
- # files, builtins plus one repo-relative module only, which
- # `repo-gates.yml`'s Pre-Install Import Graph Check enforces (objectui#6148).
- - name: Fetch the base branch so the merge base exists
- env:
- BASE_REF: ${{ github.base_ref || 'main' }}
- run: git fetch --no-tags origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}"
-
- - name: Compare this lockfile with its merge base
- env:
- BASE_REF: ${{ github.base_ref || 'main' }}
- run: node scripts/check-lockfile-integrity.mjs --base-ref "origin/${BASE_REF}"
diff --git a/ui/.github/workflows/merge-queue-head-patrol.yml b/ui/.github/workflows/merge-queue-head-patrol.yml
deleted file mode 100644
index f511380eb54..00000000000
--- a/ui/.github/workflows/merge-queue-head-patrol.yml
+++ /dev/null
@@ -1,267 +0,0 @@
-name: Merge Queue Head Patrol
-
-# The standing caller for `scripts/check-merge-queue-head.mjs`.
-#
-# ## What it watches for (objectui#7010)
-#
-# A merge-queue entry can sit at the HEAD of the queue for which GitHub never
-# dispatches `merge_group` at all. Nothing is red, nothing is ejected, and
-# everything queued behind it builds green and never merges — a merge queue is
-# strictly ordered, so a head that cannot merge blocks every lane in the
-# repository. Four occurrences are on record (2026-08-17, two on 08-31, one on
-# 09-02); the worst ran four hours and the cheapest self-healed in ~63 minutes
-# on the ruleset's status-check timeout. Nothing was watching for any of them.
-#
-# The detection is two API reads and was verified in BOTH directions before it
-# was automated (#7010, 2026-08-31T13:23Z): the wedged head answered
-# `total_count: 0` for 58 minutes, the healthy head that replaced it answered
-# the full set within the second. The script's header carries the mechanism, the
-# confounder that makes the reading safe only on the head entry, and the
-# threshold's two boundary measurements.
-#
-# ## Why a SCHEDULE, when everything else here is event-driven
-#
-# The failure is an ABSENCE — the `merge_group` event is never dispatched — and
-# an absence cannot trigger a workflow. Every event-driven alternative was
-# considered and each is blind to exactly this shape:
-#
-# `merge_group` the wedged entry produces no event; that IS the defect.
-# `push` to main a wedge means `main` never moves, so nothing ever fires.
-# `pull_request` fires on the PR, minutes before it is ever enqueued.
-#
-# So: a clock. ⚠️ Runner cost, measured rather than assumed — this repository is
-# PUBLIC, so scheduled Actions minutes are not billed at all; the real budget is
-# scheduler latency and log volume. The job is a checkout plus one `node` call
-# (no `pnpm install`, see below) and makes 3 API reads on a healthy queue.
-#
-# Cadence arithmetic, so it can be retuned against the same numbers: the wedge
-# self-heals in ~60 minutes, and the script's threshold is 5. At `*/15` a wedge
-# is reported between 5 and 20 minutes after it starts, i.e. with two thirds of
-# the wasted hour still recoverable, for 96 runs a day. `*/5` buys ~10 minutes
-# at three times the runs; hourly would routinely report a wedge that had
-# already healed.
-#
-# ## ⛔ Why there is no `pull_request` leg (a deliberate divergence)
-#
-# `half-state-patrol.yml`, the sibling this file is shaped after, carries one so
-# its transport is proven before it merges. This one must not: every job of a
-# `pull_request`-triggered workflow produces a check run, and
-# `scripts/dependabot-merge-gate.mjs` requires every produced name to be
-# classified in one of its three buckets (`merge-queue-reporting.test.ts` and
-# `dependabot-merge-gate.test.ts` partition them exactly). Adding a leg here
-# would mean editing that gate's declaration from a card that does not hold it.
-#
-# What replaces it: `node scripts/check-merge-queue-head.mjs --self-test` runs
-# offline in `scripts/__tests__/check-merge-queue-head.test.ts` on every pull
-# request, and the live transport is proven by `workflow_dispatch`, which needs
-# no merge. If a `pull_request` leg is ever wanted, it also needs a row in
-# `NOT_A_GATE` — it is a patrol, it can never gate a pull request.
-#
-# ## Where a finding lands
-#
-# Three places, in this order — the run summary always, the anchor issue when
-# one is configured, and a RED job on a wedge.
-#
-# ⛔ It never opens an issue, on any code path. A patrol that files a card per
-# firing produces one card per fifteen minutes for an hour, on an incident that
-# is one incident (objectui#7010's own history is the precedent: a later seat
-# posted a live recurrence as a COMMENT there "per the one-anchor rule rather
-# than as a new card"). `permissions:` grants `issues: write` for exactly one
-# PATCH of one pinned body and nothing else — no labels, no comments, no state.
-#
-# ⚠️ The red job is the divergence from `half-state-patrol.yml`, which is
-# report-only and fails only when the sweep could not run. Here a FINDING fails
-# the job too, for a reason specific to this defect: the remedy is a human
-# dequeue inside a 60-minute window, and an issue body edit notifies nobody
-# while a failed run does. It stays proportionate because the finding is rare —
-# four occurrences in three weeks — and because it is impossible for it to be
-# red on the healthy case: an empty queue, a settling head and an unreadable
-# reading all exit 0.
-#
-# ## The anchor is OPTIONAL here, and that is also a divergence
-#
-# `half-state-patrol.yml` fails when its anchor variable is unset, because its
-# findings have nowhere else to go. This patrol's findings do have somewhere
-# else to go (the red job above), so an unset variable is silent rather than
-# red: a patrol that failed 96 times a day over a missing setting would train
-# everyone to ignore the one failure that matters. ⛔ That is the whole reason,
-# and it is the repo's own doctrine — a permanently red check trains everyone to
-# ignore red (objectui#6596).
-#
-# TO ADD AN ANCHOR: open an issue, put its number in the repository variable
-# `MERGE_QUEUE_ANCHOR_ISSUE` (Settings -> Secrets and variables -> Actions ->
-# Variables), and this workflow will own its body from the next run on. Nothing
-# else changes.
-
-on:
- schedule:
- # Every 15 minutes, offset off the hour. The minute is not :00 on purpose:
- # scheduled workflows across GitHub bunch at the top of the hour and are
- # delayed under that load, and a patrol whose whole value is timeliness
- # should not queue behind everyone else's nightly.
- - cron: '7,22,37,52 * * * *'
- workflow_dispatch: {}
-
-# Least privilege. `actions: read` is what `GET /actions/runs` needs — the count
-# of `merge_group` runs on the head entry IS the measurement. `issues: write` is
-# the narrowest scope GitHub offers for editing one issue body.
-permissions:
- contents: read
- actions: read
- issues: write
-
-# One patrol at a time, and never cancelled: a scheduled run overlapping a
-# manual dispatch would have two runs racing to rewrite the same body, and a
-# cancelled run mid-confirmation-wait would discard a completed reading.
-concurrency:
- group: merge-queue-head-patrol
- cancel-in-progress: false
-
-env:
- # The pinned anchor issue whose body this workflow owns, or empty. The literal
- # fallback is guarded by the repository name for the reason
- # `half-state-patrol.yml` states: an issue number is only ever meaningful in
- # the repo it was minted in, and an unguarded default would let a copy of this
- # file rewrite some unrelated card in a sibling repository.
- ANCHOR_ISSUE: >-
- ${{ vars.MERGE_QUEUE_ANCHOR_ISSUE || '' }}
-
-jobs:
- patrol:
- name: Merge queue head patrol
- 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'
-
- # No `pnpm install`: the patrol imports `scripts/invoked-as.mjs` and
- # nothing else, and reaches the API through global `fetch`. Installing the
- # workspace would buy nothing and would give a scheduled patrol a lockfile
- # it could fail on. `check:pre-install-import-graph` derives this step's
- # script into its population and holds that property.
- - name: Read the head of the merge queue
- id: patrol
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GITHUB_REPOSITORY: ${{ github.repository }}
- QUEUE_BASE_BRANCH: ${{ github.event.repository.default_branch }}
- run: |
- set +e
- node scripts/check-merge-queue-head.mjs \
- > "$RUNNER_TEMP/patrol.md" 2> "$RUNNER_TEMP/patrol.err"
- code=$?
- set -e
- # Captured with NO pipe in between: `node … | tail` would report the
- # PIPE's status, and `tail` essentially never fails, so a wedge (3), a
- # clean reading (0) and an unreadable one (2) would all read as 0 —
- # which is the exact class of silent pass this patrol exists to catch.
- echo "exit_code=$code" >> "$GITHUB_OUTPUT"
- # Re-emit stderr so the one-line verdict and any `::error::` annotation
- # reach the run log; they were redirected out of it a moment ago.
- cat "$RUNNER_TEMP/patrol.err" >&2 || true
-
- - name: Refresh the pinned anchor issue
- # Skipped, not failed, when no anchor is configured — see the header.
- if: env.ANCHOR_ISSUE != ''
- uses: actions/github-script@v9
- env:
- PATROL_EXIT: ${{ steps.patrol.outputs.exit_code }}
- with:
- # Delivery is retried, never assumed: this PATCH is the whole board
- # product of the run, and a transient answer would discard a completed
- # reading.
- retries: 3
- script: |
- const fs = require('fs');
- const path = require('path');
- const anchor = Number(process.env.ANCHOR_ISSUE);
- if (!Number.isInteger(anchor) || anchor <= 0) {
- core.setFailed(`MERGE_QUEUE_ANCHOR_ISSUE is ${JSON.stringify(process.env.ANCHOR_ISSUE)}, which is not an issue number.`);
- return;
- }
- const exitCode = Number(process.env.PATROL_EXIT);
- const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
- const read = (name) => {
- try { return fs.readFileSync(path.join(process.env.RUNNER_TEMP, name), 'utf8'); }
- catch { return ''; }
- };
-
- // The composition split, deliberately: a reading that was TAKEN
- // renders its own body in the script, where `--self-test` pins every
- // property of it. Only the did-not-run body is composed here —
- // saying "my callee could not run" is the caller's job.
- const report = read('patrol.md');
- let body;
- if ((exitCode === 0 || exitCode === 3) && report.trim()) {
- body = report;
- } else {
- body = [
- 'os-merge-queue-head-patrol — machine-findable marker for this generated view.',
- '',
- `# ⛔ THE PATROL DID NOT READ THE QUEUE (exit ${exitCode})`,
- '',
- `_Attempted ${new Date().toISOString()} · [run log](${runUrl})._`,
- '',
- 'Nothing below is a finding. **The merge queue was not judged**, so this body says nothing about',
- 'whether the queue is moving — it is not a healthy queue and it is not a wedged one, it is no',
- 'reading at all. A patrol that could not run must never read as a healthy queue.',
- '',
- 'The patrol\'s own classified output:',
- '',
- '```',
- (read('patrol.err') || '(no output captured)').trim(),
- '```',
- ].join('\n');
- }
-
- body += `\n\n_Read ${new Date().toISOString()} · [run log](${runUrl}) · patrol exit ${exitCode}._\n`;
-
- await github.rest.issues.update({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: anchor,
- body,
- });
- core.info(`anchor #${anchor} refreshed (${body.length} chars, patrol exit ${exitCode})`);
-
- - name: Publish the reading to the run summary
- # Always: on a run with no anchor configured this IS the delivery, and on
- # every other run it makes the log self-contained when someone opens it
- # after an alert.
- if: always()
- run: |
- {
- echo "### Merge queue head patrol — exit ${{ steps.patrol.outputs.exit_code }}"
- echo
- cat "$RUNNER_TEMP/patrol.md" 2>/dev/null || echo '_(no reading produced)_'
- echo
- echo 'stderr
'
- echo
- echo '```'
- cat "$RUNNER_TEMP/patrol.err" 2>/dev/null || true
- echo '```'
- echo
- echo ' '
- } >> "$GITHUB_STEP_SUMMARY"
-
- - name: Fail the run on a wedge, or when the queue could not be read
- # LAST, on purpose: the anchor and the summary carry the reading BEFORE
- # the job goes red. Land the truth, then raise the alarm.
- #
- # Exit 0 covers every reading that is not a finding — a healthy head, an
- # empty queue, a head too young to judge, and a head that could not be
- # identified. Only 3 (wedged) and 2 (could not read) reach here.
- if: steps.patrol.outputs.exit_code != '0'
- run: |
- if [ "${{ steps.patrol.outputs.exit_code }}" = "3" ]; then
- echo "::error::The merge queue is WEDGED — its head entry has no merge_group build and every queued pull request is blocked behind it. Remove that entry from the merge queue; the reading is in this run's summary."
- else
- echo "::error::check-merge-queue-head exited ${{ steps.patrol.outputs.exit_code }} — the queue was NOT read. This is not a healthy-queue reading."
- fi
- exit 1
diff --git a/ui/.github/workflows/node-esm-load-gate.yml b/ui/.github/workflows/node-esm-load-gate.yml
deleted file mode 100644
index ef067111b8e..00000000000
--- a/ui/.github/workflows/node-esm-load-gate.yml
+++ /dev/null
@@ -1,125 +0,0 @@
-name: Node ESM Load Gate
-
-# The artifact-level half of objectui#4538: every published ESM package must be
-# importable by Node's own resolver, with no bundler and no loader hooks. The
-# gate, and why a resolution-only criterion was measured and rejected, are
-# documented at length in scripts/check-node-esm-load.mjs.
-#
-# "Every" has one ruled exception, and it is a product boundary rather than a
-# backlog item: the three style-carrying plugin packages named in
-# UNBUNDLED_NODE_UNSUPPORTED import a `.css` file at module scope, which Node
-# cannot load at all, and objectui#5384 ruled unbundled Node consumption
-# unsupported for them permanently. They are printed on every run, never
-# silently subtracted, and the specifier leg — not this one — is what guards
-# them against a real regression.
-#
-# ── Why this is a scheduled workflow and NOT a pull-request job ──────────────
-#
-# The load leg has to EVALUATE each entry, so it has to build all 39 published
-# packages. This repository deliberately has no per-PR full-repo build —
-# `ci.yml`'s `Build & E2E` builds `@object-ui/console` alone, and its `Type
-# Check` gets only the DEPENDENCY CLOSURE out of turbo's `dependsOn:
-# ["^build"]`, so leaf packages such as `@object-ui/plugin-map` are never built
-# there. `published-dist-gate.yml` records the same trade for the same reason,
-# and the 2026-08-16 ruling on objectui#4846 rejected paying a full build per
-# pull request to catch a defect that reaches a user at publish time.
-#
-# ⛔ Do not add `pull_request` here. The per-PR half of objectui#4538 is the
-# CHEAP leg — `pnpm check:esm-specifiers`, a source-level scan that needs no
-# build — and it already runs in `ci.yml`'s `Type Check` job. That leg is the
-# ratchet; this workflow is the proof that the ratchet guards the right
-# property, because it is the only thing that can catch a package broken by a
-# DEPENDENCY rather than by its own sources (measured: four plugin packages
-# failing on `packages/mobile/dist/useBreakpoint`, which is not their file).
-#
-# This workflow is therefore an ALARM, not a merge blocker: it cannot make
-# anyone's pull request red, so a slow run costs a second look and nothing else.
-
-on:
- workflow_dispatch:
-
- # Nightly at 04:17 UTC — after `published-dist-gate.yml`'s 03:41 so the two
- # full builds do not contend, and off the top of the hour because GitHub's
- # scheduled queue is most congested, and most delayed, at :00.
- schedule:
- - cron: '17 4 * * *'
-
- # Changes to the gate itself get a real run. Its unit tests DO run per-PR
- # inside the ordinary vitest suite, but they exercise the verdicts over
- # synthetic fixtures and cannot exercise a real build.
- push:
- branches: [main]
- paths:
- - 'scripts/check-node-esm-load.mjs'
- - '.github/workflows/node-esm-load-gate.yml'
-
-concurrency:
- group: node-esm-load-gate-${{ github.ref }}
- cancel-in-progress: true
-
-permissions:
- contents: read
-
-jobs:
- node-esm-load:
- name: Node ESM Load Scan
- runs-on: ubuntu-latest
- # A full build of every published package plus one child `node` import per
- # package. Generous, because a timeout here reads as a gate failure and this
- # workflow blocks nobody.
- timeout-minutes: 60
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v7
- with:
- submodules: true
-
- - name: Enable Corepack and download the pinned pnpm
- run: bash scripts/ci-setup-pnpm.sh
-
- - name: Setup Node.js
- uses: actions/setup-node@v7
- with:
- node-version: '22.x'
- cache: 'pnpm'
-
- # objectui#12957: every @objectstack/* package resolves to the build of the
- # commit `.objectstack-sha` pins, read from node_modules/.cache/objectstack, so
- # it must be there before pnpm resolves anything. Restored by the pin and built
- # only on a miss (scripts/objectstack-pin.mjs); the save is this job's last step.
- - name: Restore the pinned @objectstack build
- id: objectstack-pin
- uses: actions/cache/restore@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
-
- - name: Build @objectstack at the pin (skips a restored build)
- id: objectstack-build
- run: node scripts/objectstack-pin.mjs build
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- # No `pnpm build` step before this one, on purpose. The gate runs the build
- # ITSELF and fails when it cannot, so that "there were no artifacts,
- # therefore nothing was wrong" can never be this workflow's verdict. A
- # published package whose entry is missing after the build is reported as
- # `no-build-output`, not skipped, and the run asserts a floor on how many
- # entries actually evaluated.
- - name: Build every published package and import its entry under plain Node
- run: pnpm check:node-esm-load
-
- # The pinned @objectstack build, saved only when this job built it (a cache
- # miss) and whatever the verdict above was -- it does not depend on it.
- # Bookkeeping: bounded and non-fatal (objectui#6577, objectui#7048). The bound
- # is not yet derived from a measured save; re-derive it from this step's runs.
- - name: Save the pinned @objectstack build
- if: ${{ !cancelled() && steps.objectstack-build.outcome == 'success' && steps.objectstack-pin.outputs.cache-hit != 'true' }}
- continue-on-error: true
- timeout-minutes: 2
- uses: actions/cache/save@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
diff --git a/ui/.github/workflows/performance-budget.yml b/ui/.github/workflows/performance-budget.yml
deleted file mode 100644
index aec17a4c3c0..00000000000
--- a/ui/.github/workflows/performance-budget.yml
+++ /dev/null
@@ -1,750 +0,0 @@
-name: Bundle Analysis
-
-on:
- push:
- branches: [main, develop]
- paths:
- - 'packages/**'
- - 'apps/console/**'
- - 'pnpm-lock.yaml'
- # The console bundles @objectstack/* from the build of the commit this file
- # pins, so a pin bump moves the bundle without touching the lockfile
- # (objectui#12957); the script that builds it runs in this job.
- - '.objectstack-sha'
- - 'scripts/objectstack-pin.mjs'
- # This file itself (objectui#6245). Without it a change to this gate ships
- # to `main` having never run once: the PR that edits it touches no
- # `packages/**` path, so `Bundle Analysis` never appears on it, and a
- # wiring bug surfaces on the NEXT `packages/**` PR — a red check on
- # someone else's diff, reading to them as a bundle problem of their own.
- # ⚠️ This argument does not turn on how the check is CLASSIFIED. It said
- # otherwise until objectui#9155, and the conclusion survives losing that
- # premise untouched: a gate that never runs on its own pull request ships
- # its wiring bugs to the next person either way.
- # Self-inclusion is this repo's convention for a
- # path-filtered gate: measured on `origin/main`, 5 of the 7 workflows
- # carrying a `paths:` filter list their own file, and this was one of the
- # two that did not. The cost is honest and accepted — every edit of this
- # file now pays a console build — and it is the same tax the other
- # self-included gates already pay.
- - '.github/workflows/performance-budget.yml'
- # ...and the gate's RUNTIME CLOSURE: the files this job actually executes.
- # Listing the YAML alone was still not enough — a PR touching only
- # `check-eager-closure-budget.mjs`, the file that computes the verdict and
- # the one this card is about, did not trigger this gate. The convention
- # measured on `origin/main` is the closure, not just the workflow:
- # `half-state-patrol.yml` lists its script AND `scripts/invoked-as.mjs`,
- # the dependency of that script.
- #
- # `invoked-as.mjs` is here for the same reason it is there, and the reason
- # is specific rather than tidy: `isEntrypoint` decides whether `main()`
- # runs at all, so a regression in it makes this checker exit 0 having
- # measured nothing — the exact silent-green failure the rest of this gate
- # exists to prevent.
- #
- # NOT listed: `scripts/__tests__/*.test.ts`. This job never runs vitest —
- # it runs two `node scripts/*.mjs` commands — so a test-only edit cannot
- # change this gate's verdict, and those tests already run on every PR in
- # the root vitest `unit` project. `published-dist-gate.yml` and
- # `spec-range-floors.yml` draw the same line.
- - 'scripts/check-eager-closure-budget.mjs'
- # The COMPOSITION half (objectui#7479). Same rule as its neighbour: this
- # job runs it, so an edit to it must be able to red this gate on its own
- # pull request rather than on somebody else's next `packages/**` change.
- - 'scripts/check-eager-locale-catalogues.mjs'
- - 'scripts/render-budget-comment.mjs'
- - 'scripts/invoked-as.mjs'
- # The SDUI registration pins (objectui#6683) and their two dependencies.
- # Same rule as the three above — this job runs them, so an edit to any of
- # them must be able to turn this gate red on its OWN PR rather than on
- # somebody else's next `packages/**` change.
- - 'scripts/check-sdui-registration-pins.mjs'
- - 'scripts/check-side-effects-array.mjs'
- - 'scripts/component-registrations.mjs'
- - 'scripts/js-comment-mask.mjs'
- # The turbo cache report and prune (objectui#12276). Same rule again:
- # this job runs it, so an edit to it must run this gate on its own pull
- # request.
- - 'scripts/report-turbo-cache.mjs'
- pull_request:
- branches: [main, develop]
- paths:
- - 'packages/**'
- - 'apps/console/**'
- - 'pnpm-lock.yaml'
- # The console bundles @objectstack/* from the build of the commit this file
- # pins, so a pin bump moves the bundle without touching the lockfile
- # (objectui#12957); the script that builds it runs in this job.
- - '.objectstack-sha'
- - 'scripts/objectstack-pin.mjs'
- # This file itself (objectui#6245). Without it a change to this gate ships
- # to `main` having never run once: the PR that edits it touches no
- # `packages/**` path, so `Bundle Analysis` never appears on it, and a
- # wiring bug surfaces on the NEXT `packages/**` PR — a red check on
- # someone else's diff, reading to them as a bundle problem of their own.
- # ⚠️ This argument does not turn on how the check is CLASSIFIED. It said
- # otherwise until objectui#9155, and the conclusion survives losing that
- # premise untouched: a gate that never runs on its own pull request ships
- # its wiring bugs to the next person either way.
- # Self-inclusion is this repo's convention for a
- # path-filtered gate: measured on `origin/main`, 5 of the 7 workflows
- # carrying a `paths:` filter list their own file, and this was one of the
- # two that did not. The cost is honest and accepted — every edit of this
- # file now pays a console build — and it is the same tax the other
- # self-included gates already pay.
- - '.github/workflows/performance-budget.yml'
- # ...and the gate's RUNTIME CLOSURE: the files this job actually executes.
- # Listing the YAML alone was still not enough — a PR touching only
- # `check-eager-closure-budget.mjs`, the file that computes the verdict and
- # the one this card is about, did not trigger this gate. The convention
- # measured on `origin/main` is the closure, not just the workflow:
- # `half-state-patrol.yml` lists its script AND `scripts/invoked-as.mjs`,
- # the dependency of that script.
- #
- # `invoked-as.mjs` is here for the same reason it is there, and the reason
- # is specific rather than tidy: `isEntrypoint` decides whether `main()`
- # runs at all, so a regression in it makes this checker exit 0 having
- # measured nothing — the exact silent-green failure the rest of this gate
- # exists to prevent.
- #
- # NOT listed: `scripts/__tests__/*.test.ts`. This job never runs vitest —
- # it runs two `node scripts/*.mjs` commands — so a test-only edit cannot
- # change this gate's verdict, and those tests already run on every PR in
- # the root vitest `unit` project. `published-dist-gate.yml` and
- # `spec-range-floors.yml` draw the same line.
- - 'scripts/check-eager-closure-budget.mjs'
- # The COMPOSITION half (objectui#7479). Same rule as its neighbour: this
- # job runs it, so an edit to it must be able to red this gate on its own
- # pull request rather than on somebody else's next `packages/**` change.
- - 'scripts/check-eager-locale-catalogues.mjs'
- - 'scripts/render-budget-comment.mjs'
- - 'scripts/invoked-as.mjs'
- # The SDUI registration pins (objectui#6683) and their two dependencies.
- # Same rule as the three above — this job runs them, so an edit to any of
- # them must be able to turn this gate red on its OWN PR rather than on
- # somebody else's next `packages/**` change.
- - 'scripts/check-sdui-registration-pins.mjs'
- - 'scripts/check-side-effects-array.mjs'
- - 'scripts/component-registrations.mjs'
- - 'scripts/js-comment-mask.mjs'
- # The turbo cache report and prune (objectui#12276). Same rule again:
- # this job runs it, so an edit to it must run this gate on its own pull
- # request.
- - 'scripts/report-turbo-cache.mjs'
-
-concurrency:
- group: bundle-analysis-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
-
-permissions:
- contents: read
- pull-requests: write
-
-jobs:
- bundle-analysis:
- name: Bundle Analysis
- runs-on: ubuntu-latest
-
- # ── The job ceiling, DERIVED FOR THIS JOB (objectui#7270) ────────────
- # Before this line the only backstop was GitHub's 360-minute default, on a
- # job that BLOCKS WHENEVER IT RUNS: `scripts/dependabot-merge-gate.mjs`
- # lists `Bundle Analysis` under `OPTIONAL_CONTEXTS`, whose rule is present
- # => must be `success`, absent => ignored. The word "optional" there is
- # about this workflow's trigger-level path filter, NOT about the check being
- # advisory, and that gate's own docblock says so (objectui#6245,
- # objectui#9155). Two
- # incidents put a number on that exposure: objectui#5304 (`apt-get` hang)
- # and objectui#6577 (cache-save stall), both of which turned a transient
- # fault into a `cancelled` check — a gate that reports nothing.
- #
- # ⚠️ Measured while deriving this, and worth knowing before reusing the
- # `Lint` argument here: this workflow subscribes only `push` and
- # `pull_request`, NOT `merge_group`, and 300 sampled runs contain zero
- # `merge_group` runs. So this job cannot hold the merge queue the way
- # `Lint` can; what six hours costs here is the pull request and the runner.
- # The ceiling is still derived, because that exposure is real on its own.
- #
- # ⛔ NOT inherited from `ci.yml`'s 10/15/20/30/40 — objectui#7048 fences
- # exactly that. The arithmetic below is this job's own.
- #
- # - Population: the last 300 successful runs of this workflow, window
- # 2026-09-03T11:40Z .. 2026-09-06T04:10Z, n=300 `Bundle Analysis` jobs.
- # Per-job wall clock from the Actions jobs endpoint (`completed_at`
- # minus `started_at`), never the run's total.
- # - min 57s / median 3m35s / p95 4m37s / max 5m23s.
- # - Broad rather than bimodal: 72 runs under 2m and 228 at or above it,
- # spread continuously across the range (Turbo cache hit versus miss),
- # with no isolated tail.
- # - By event: pull_request n=191 (median 3m37s, max 5m23s), push n=109
- # (median 3m31s, max 4m47s). No material difference.
- # - max/p95 = 1.17 — no fat tail, so the multiplier is not widened.
- #
- # Ceiling = the smallest round number that is both >= 3x max (16.2min) and
- # >= max + 15min (20.4min) => 25; the second leg is the one that binds.
- # That is ~4.6x the slowest run ever observed here.
- #
- # ⛔ Raising this toward 360 is the ruled-out non-fix (objectui#6577,
- # objectui#7048): the hang just runs longer and the gate still reports
- # `cancelled`.
- timeout-minutes: 25
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v7
- with:
- submodules: true
- # Depth 2, not 1: on a `pull_request` run HEAD is the MERGE REF, whose
- # first parent is the base commit this checkout was computed against.
- # The ceiling-freshness half (objectui#6245) needs that commit's copy
- # of the checker to tell a stale checkout from a PR that re-baselines
- # on purpose, and one extra commit is cheaper than a second fetch.
- fetch-depth: 2
-
- - name: Enable Corepack and download the pinned pnpm
- run: bash scripts/ci-setup-pnpm.sh
-
- - name: Verify pnpm version
- run: pnpm --version
-
- - name: Setup Node.js
- uses: actions/setup-node@v7
- with:
- node-version: '22.x'
- cache: 'pnpm'
-
- # ── Cache bookkeeping cannot void a recorded verdict (objectui#7048) ──
- # THE ORDERING, carried here from the type-check split in `ci.yml`
- # (objectui#6577, PR #7047) because it is what a future reader needs in
- # order to judge whether these steps may be touched: the verdict is
- # recorded by the checking steps; everything after them is bookkeeping,
- # and bookkeeping must never discard an answer the gate already produced.
- #
- # Why the SPLIT rather than a timeout on one step: combined
- # `actions/cache` declares `main: dist/restore/index.js` plus
- # `post: dist/save/index.js`, so its save is a step the RUNNER generates
- # at job end (`Post Turbo Cache`). No workflow syntax attaches
- # `timeout-minutes` or `continue-on-error` to a generated post step.
- # Measured once, on `ci.yml`'s type-check cache: a 1-second save took
- # 789s, ran that job into its ceiling, and the resulting `cancelled` —
- # which the merge queue cannot tell from `failure` — ejected an all-green
- # pull request (objectui#6577). `actions/cache`'s own `save-always`
- # deprecation text points at this same split.
- #
- # ⚠️ This job's ceiling is `timeout-minutes: 25`, DERIVED at the job
- # header above from this job's own run distribution (objectui#7270).
- # Until that line existed the ceiling was GitHub's 360-minute default on
- # a check that blocks whenever it runs (objectui#6245; the classification
- # is cited once, at the job header above — objectui#9155), so a stalled
- # save would have held that check open for up to six hours before
- # reporting `cancelled`. The
- # bound below is still on the bookkeeping step alone, and both numbers
- # are needed: 25 minutes is the backstop, 5 minutes is what a healthy
- # save is allowed.
- #
- # The restore half is deliberately left UNBOUNDED: a restore stall fails
- # BEFORE any verdict exists — a gate that did not run, which is honest —
- # rather than a recorded verdict discarded.
- #
- # ONE restore key, this job's own prefix (objectui#12276, the shape
- # `ci.yml`'s `type-check` job took in objectui#12222). Until then this key
- # was `turbo--` with a bare `turbo--` fallback, and `Lint`
- # and the release job saved under that same key, so on a pull request
- # this job restored whichever cache reached the ref first -- `Lint`'s
- # logs-only entry as readily as a build -- and saved it again.
- # Pull-request runs save nothing now (see the prune step below), so on a
- # pull request's ref this key finds nothing and falls through to `main`.
- - name: Restore Turbo Cache
- id: turbo-cache
- uses: actions/cache/restore@v6
- with:
- path: .turbo/cache
- key: turbo-${{ runner.os }}-perf-${{ github.sha }}
- restore-keys: |
- turbo-${{ runner.os }}-perf-
-
- # objectui#12957: every @objectstack/* package resolves to the build of the
- # commit `.objectstack-sha` pins, read from node_modules/.cache/objectstack, so
- # it must be there before pnpm resolves anything. Restored by the pin and built
- # only on a miss (scripts/objectstack-pin.mjs); the save is this job's last step.
- - name: Restore the pinned @objectstack build
- id: objectstack-pin
- uses: actions/cache/restore@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
-
- - name: Build @objectstack at the pin (skips a restored build)
- id: objectstack-build
- run: node scripts/objectstack-pin.mjs build
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- # `TURBO_RUN_SUMMARY` makes turbo write its run summary to `.turbo/runs/`;
- # it changes nothing turbo runs or caches, and `Report what the turbo
- # cache replayed` below reads it (objectui#12276).
- - name: Build packages
- id: build_packages
- env:
- TURBO_RUN_SUMMARY: 'true'
- run: pnpm turbo run build --filter='./packages/*'
-
- - name: Build Console
- run: pnpm --filter @object-ui/console build
-
- # objectui#6245: GitHub does not re-run a PR's checks when the base branch
- # moves — so a green verdict can be computed against ceiling constants
- # `main` has since replaced, and what this job publishes is a verdict about
- # a ceiling that no longer exists. ⛔ Nothing is claimed here about the
- # branch-protection set (objectui#9155); `evaluateCeilingFreshness`'s
- # docblock in `scripts/check-eager-closure-budget.mjs` carries the two
- # readings the tree can re-derive. Observed
- # live: run 32804357171 started at 03:13:27Z, 6m50s after `0409b766d`
- # lowered MAX_EAGER_CLOSURE_GZIP_BYTES from 4,086,000 to 3,345,000, and
- # published `BUDGET_CLOSURE_BUDGET_KB: 3990.2` — the retired ceiling — as a
- # success.
- #
- # This step hands the checker THREE readings of the constants: the two
- # below plus its own checkout. Two would not do — a re-baseline PR differs
- # from the base branch deliberately and has to stay landable — so the
- # verdict fires only when the base branch moved a ceiling AND this checkout
- # does not carry the move. See `evaluateCeilingFreshness`.
- #
- # `continue-on-error` is deliberate and is NOT a fail-open: when this step
- # cannot produce both files the checker finds the variables unset and
- # reports a freshness ERROR (exit 2), which fails the job. Letting the step
- # itself fail the job would kill it before the bundle is measured, and a
- # run that hides its numbers is how this gate loses its readers
- # (objectui#3152).
- #
- # The paths go out through `$GITHUB_ENV` rather than a step `env:` block:
- # `render-budget-comment.test.ts` walks every `NAME: ${{ steps.* }}` line
- # in this file and requires the COMMENT RENDERER to read it, which is the
- # right rule for the comment's inputs and the wrong one for the checker's.
- - name: Resolve the base-branch ceiling constants
- id: base_ceilings
- if: ${{ github.event_name == 'pull_request' }}
- continue-on-error: true
- env:
- PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
- BASE_REF: ${{ github.base_ref }}
- run: |
- set -euo pipefail
- CHECKER='scripts/check-eager-closure-budget.mjs'
- OUT="${RUNNER_TEMP}/eager-closure-ceilings"
- mkdir -p "$OUT"
-
- # The base commit the merge ref was computed against. `fetch-depth: 2`
- # normally makes it a local object already; the fetch is the fallback
- # for a merge ref GitHub recomputed against a different base.
- if ! git cat-file -e "${PR_BASE_SHA}^{commit}" 2>/dev/null; then
- git fetch --no-tags --depth=1 origin "$PR_BASE_SHA"
- fi
- git cat-file -p "${PR_BASE_SHA}:${CHECKER}" > "$OUT/pr-base.partial"
- mv "$OUT/pr-base.partial" "$OUT/pr-base.mjs"
-
- # The base branch as it stands right now — what the merge would land on.
- git fetch --no-tags --depth=1 origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}"
- BASE_SHA=$(git rev-parse "refs/remotes/origin/${BASE_REF}")
- git cat-file -p "${BASE_SHA}:${CHECKER}" > "$OUT/base.partial"
- mv "$OUT/base.partial" "$OUT/base.mjs"
-
- # Written only after BOTH `mv`s: a half-populated pair would let the
- # checker compare against a file it thinks is the base branch.
- {
- echo "EAGER_CLOSURE_PR_BASE_SOURCE=$OUT/pr-base.mjs"
- echo "EAGER_CLOSURE_BASE_SOURCE=$OUT/base.mjs"
- echo "EAGER_CLOSURE_PR_BASE_SHA=$PR_BASE_SHA"
- echo "EAGER_CLOSURE_BASE_SHA=$BASE_SHA"
- echo "EAGER_CLOSURE_BASE_REF=$BASE_REF"
- } >> "$GITHUB_ENV"
-
- echo "Ceilings resolved: ${PR_BASE_SHA} (this checkout's base) vs ${BASE_SHA} (${BASE_REF} now)"
-
- - name: Check console performance budget
- id: budget
- run: |
- # TWO measurements, one verdict.
- #
- # 1. The `index-*.js` entry chunk against a 350 KB line. This is the
- # original budget and it stays: a fat entry chunk is still worth
- # its own signal, and replacing a gauge is not licence to weaken
- # the one already here.
- # 2. The EAGER CLOSURE — every chunk the entry reaches through STATIC
- # imports, i.e. everything the browser must fetch and parse before
- # the app renders.
- #
- # Measurement 1 alone was the whole budget until objectui#5324. On
- # `77f846a8b` the entry chunk gzips to 25.9 KB against the 350 KB line
- # while the closure it pulls in is 3,881,609 bytes across 58 of 507
- # chunks — so the gate passed on 0.67% of the payload it claimed to
- # govern, and the 89 KiB regression of objectui#5266 landed in
- # `vendor-objectstack-*.js` where nothing here could see it.
- # `advancedChunks` routes vendor and workspace code into named chunks
- # on purpose, so MOST regressions land outside `index-*.js`.
- #
- # The closure ceiling is NOT set here. It lives in
- # `scripts/check-eager-closure-budget.mjs` next to the reasoning that
- # produced it, so a re-baseline is one edit against a documented
- # argument rather than a number in YAML: today's measured payload plus
- # half the 89 KiB regression the gate exists to catch, which keeps the
- # headroom NARROWER than that regression while leaving as much room for
- # the payload to shrink as to grow. Since objectui#5924 the checker
- # enforces that itself, against the report it just read rather than
- # against a frozen constant, for the aggregate ceiling and each
- # per-chunk one — a ceiling that has drifted out of range of the
- # regression it must catch is exit 2, a verdict about the gauge. It is
- # a truthful current-state ceiling, not a statement that the payload it
- # admits is fine.
- #
- # ⛔ That clause names no size, and none may be put back
- # (objectui#7528). It used to end by naming one, and the size it named
- # was the ceiling objectui#6683 set and objectui#6776 retired — so the
- # one sentence written to stop a reader concluding the payload is
- # acceptable was itself quoting a ceiling the gate had not allowed
- # since, which is why the repair here is no size rather than a fresher
- # one. The rule this paragraph
- # already applies to the ceiling covers the ceiling's VALUE too: the
- # constant lives beside the reasoning that produced it, and
- # `pnpm check:eager-closure` prints the measured payload, the ceiling
- # and the headroom together in one verdict line. Neither number is
- # copied to here, where nothing would fail when it moved.
- #
- # Both measurements run before either may fail the step: a run that
- # reports one number and hides the other teaches readers to distrust
- # the comment, which is how objectui#3152 nearly took this gate down.
- MAX_ENTRY_GZIP_KB=350
-
- DIST_DIR="apps/console/dist/assets"
- if [ ! -d "$DIST_DIR" ]; then
- echo "❌ Build output not found at $DIST_DIR"
- # Declare the outcome instead of leaving every output empty. An
- # empty `budget_status` used to render as a ❌ FAIL verdict
- # downstream (objectui#3152); the comment renderer now needs each
- # path to say what happened rather than infer it from silence.
- echo "budget_status=error" >> "$GITHUB_OUTPUT"
- echo "budget_message=Build output not found at $DIST_DIR" >> "$GITHUB_OUTPUT"
- exit 1
- fi
-
- # Find the main entry chunk (index-*.js)
- ENTRY_FILE=$(find "$DIST_DIR" -name 'index-*.js' -not -name '*.gz' -not -name '*.br' | head -1)
- if [ -z "$ENTRY_FILE" ]; then
- echo "⚠️ Could not find main entry chunk, checking all JS files..."
- ENTRY_FILE=$(find "$DIST_DIR" -name '*.js' -not -name '*.gz' -not -name '*.br' | sort | head -1)
- fi
-
- if [ -z "$ENTRY_FILE" ]; then
- echo "❌ No JS files found in $DIST_DIR"
- echo "budget_status=error" >> "$GITHUB_OUTPUT"
- echo "budget_message=No JS files found in $DIST_DIR" >> "$GITHUB_OUTPUT"
- exit 1
- fi
-
- echo "📦 Main entry file: $(basename $ENTRY_FILE)"
-
- # Calculate gzip size
- GZIP_BYTES=$(gzip -c "$ENTRY_FILE" | wc -c)
- GZIP_KB=$(awk "BEGIN {printf \"%.1f\", $GZIP_BYTES / 1024}")
-
- echo " Raw size: $(awk "BEGIN {printf \"%.1f\", $(wc -c < "$ENTRY_FILE") / 1024}") KB"
- echo " Gzip size: ${GZIP_KB} KB"
- echo " Budget: ${MAX_ENTRY_GZIP_KB} KB"
-
- echo "gzip_kb=$GZIP_KB" >> "$GITHUB_OUTPUT"
- echo "budget_kb=$MAX_ENTRY_GZIP_KB" >> "$GITHUB_OUTPUT"
- echo "entry_file=$(basename $ENTRY_FILE)" >> "$GITHUB_OUTPUT"
-
- ENTRY_OVER=$(awk "BEGIN {print ($GZIP_KB > $MAX_ENTRY_GZIP_KB) ? 1 : 0}")
- if [ "$ENTRY_OVER" -eq 1 ]; then
- echo "❌ ENTRY BUDGET EXCEEDED: Main entry is ${GZIP_KB} KB gzip (limit: ${MAX_ENTRY_GZIP_KB} KB)"
- else
- echo "✅ Entry budget OK: Main entry is ${GZIP_KB} KB gzip (limit: ${MAX_ENTRY_GZIP_KB} KB)"
- fi
-
- echo ""
- echo "📦 Eager closure (what a page load actually pays for):"
- # Writes closure_status / closure_gzip_kb / closure_budget_kb /
- # closure_chunks / closure_chunk_status / closure_membership_status /
- # closure_headroom_status to
- # $GITHUB_OUTPUT itself. EVERY key it publishes is passed into the
- # comment step below and rendered there — a verdict published to
- # $GITHUB_OUTPUT that no consumer reads is a verdict the PR comment
- # cannot name, which is what objectui#6230 was (the per-chunk and
- # sensitivity halves both went unread). A fourth half added here must
- # be wired through too; `render-budget-comment.test.ts` fails if it is
- # not. The fourth half — ceiling freshness, objectui#6245 — arrived
- # through exactly that door: it publishes `closure_freshness_status`,
- # and is wired into the comment step below.
- #
- # Exit codes are distinct on
- # purpose: 1 = over budget (a verdict about the bundle), 2 = no
- # trustworthy measurement (a verdict about the gauge). Reporting one as
- # the other is how a broken gauge gets read as a size regression, and a
- # size regression as a broken gauge.
- set +e
- node scripts/check-eager-closure-budget.mjs
- CLOSURE_CODE=$?
- set -e
-
- if [ "$CLOSURE_CODE" -eq 2 ]; then
- echo "budget_status=error" >> "$GITHUB_OUTPUT"
- echo "budget_message=The entry chunk measured ${GZIP_KB} KB, but the eager-closure half of this gate returned no trustworthy VERDICT: the report could not be read, a ceiling has drifted out of range of the regression it must catch, or (objectui#6245) a ceiling was replaced on the base branch after this checkout was made. The step log says which. This is not a passing budget — and it is not a size regression either." >> "$GITHUB_OUTPUT"
- exit 1
- fi
-
- if [ "$ENTRY_OVER" -eq 1 ] || [ "$CLOSURE_CODE" -ne 0 ]; then
- echo ""
- echo "❌ BUDGET EXCEEDED"
- echo "budget_status=fail" >> "$GITHUB_OUTPUT"
- exit 1
- fi
-
- # The COMPOSITION half (objectui#7479): WHICH locale catalogues a page
- # load pulls, which no byte ceiling can answer. The catalogues could
- # return to the eager closure one at a time, each arrival small enough
- # to fit inside a ceiling's headroom, and every run above would be
- # green until the last one — and they could be routed into a roomier
- # chunk without a single byte leaving the browser's request list,
- # which objectui#7399 named and refused. Same three exit codes and the
- # same reading of them: 2 is about the GAUGE, never a softer 1.
- set +e
- node scripts/check-eager-locale-catalogues.mjs
- CATALOGUE_CODE=$?
- set -e
-
- if [ "$CATALOGUE_CODE" -eq 2 ]; then
- echo "budget_status=error" >> "$GITHUB_OUTPUT"
- echo "budget_message=The byte budgets passed, but the locale-catalogue composition half returned no trustworthy VERDICT: the eager-closure report could not be read, a catalogue was emitted under no chunk of its own, or the resident catalogue is not in the eager closure at all. The step log says which. This is not a passing budget — and it is not a size regression either." >> "$GITHUB_OUTPUT"
- exit 1
- fi
-
- if [ "$CATALOGUE_CODE" -ne 0 ]; then
- echo ""
- echo "❌ NON-RESIDENT LOCALE CATALOGUES ARE IN THE EAGER CLOSURE"
- echo "budget_status=fail" >> "$GITHUB_OUTPUT"
- exit 1
- fi
-
- echo ""
- echo "✅ Budget OK: entry chunk and eager closure are both within budget, and one locale catalogue is eager"
- echo "budget_status=pass" >> "$GITHUB_OUTPUT"
-
- # The ARTIFACT half of the `sideEffects` contract (objectui#6683). The
- # static gate in `ci.yml` proves the array agrees with the module bodies;
- # it cannot prove that a real bundler reading that array still EMITS the
- # registrations, and that is the question the hazard turns on —
- # `"sideEffects": false` is statically coherent and drops three live SDUI
- # widget registrations out of the build entirely — they reach no chunk at
- # all — on a green build (objectui#6535).
- #
- # The key set is derived from the array itself, so this step needs no
- # list of its own. It runs after the budget step and never instead of it:
- # a dropped registration and a size regression are different verdicts, and
- # a size win bought by deleting a feature must not read as a size win.
- #
- # Not `if: always()` — with no `dist/` the checker exits 2 and says so,
- # which is the honest verdict for a run that measured nothing, but there
- # is no reason to spend it on a build that already failed.
- - name: Pin the SDUI registrations the `sideEffects` array promises
- run: pnpm check:sdui-registration-pins
-
- - name: Generate package size report
- id: size-report
- # NOT `always()`. `always()` also fires on a cancelled run, where
- # `packages/*/dist` is only partly populated — the report was then
- # emitted from that partial tree and looked complete while silently
- # missing 7 packages (objectui#3152). The report is only meaningful
- # once every package has finished building, so gate it on that.
- if: ${{ !cancelled() && steps.build_packages.outcome == 'success' }}
- run: |
- echo "## 📦 Bundle Size Report" > size-report.md
- echo "" >> size-report.md
- echo "| Package | Size | Gzipped |" >> size-report.md
- echo "|---------|------|---------|" >> size-report.md
-
- for pkg in packages/*/dist; do
- if [ -d "$pkg" ]; then
- pkg_name=$(basename $(dirname $pkg))
-
- # Calculate sizes for main bundle files
- for file in "$pkg"/*.js; do
- if [ -f "$file" ] && [ ! -f "${file}.map" ]; then
- # Use portable method to get file size
- size=$(wc -c < "$file")
- size_kb=$(awk "BEGIN {printf \"%.2f\", $size/1024}")
-
- # Estimate gzipped size
- gzip_size=$(gzip -c "$file" | wc -c)
- gzip_kb=$(awk "BEGIN {printf \"%.2f\", $gzip_size/1024}")
-
- echo "| $pkg_name ($(basename $file)) | ${size_kb}KB | ${gzip_kb}KB |" >> size-report.md
- fi
- done
- fi
- done
-
- echo "" >> size-report.md
- echo "### Size Limits" >> size-report.md
- echo "- ✅ Core packages should be < 50KB gzipped" >> size-report.md
- echo "- ✅ Component packages should be < 100KB gzipped" >> size-report.md
- echo "- ⚠️ Plugin packages should be < 150KB gzipped" >> size-report.md
-
- # Rendering lives in `scripts/render-budget-comment.mjs` so it can be unit
- # tested (`scripts/__tests__/render-budget-comment.test.ts`) — the bug this
- # replaces was purely a rendering bug, and logic inlined in YAML is
- # untestable. Step outputs are passed through `env` rather than
- # interpolated into a JS string literal, so an absent output stays an
- # empty *variable* instead of vanishing into source text.
- - name: Render performance budget comment
- id: render_comment
- # `!cancelled()`, NOT `always()`: a cancelled run measured nothing, and
- # the superseding run posts the real verdict moments later. Commenting
- # on cancellation is what produced a ❌ FAIL on every PR that got a
- # second push (objectui#3152).
- if: ${{ github.event_name == 'pull_request' && !cancelled() }}
- env:
- BUDGET_STATUS: ${{ steps.budget.outputs.budget_status }}
- BUDGET_MESSAGE: ${{ steps.budget.outputs.budget_message }}
- BUDGET_GZIP_KB: ${{ steps.budget.outputs.gzip_kb }}
- BUDGET_LIMIT_KB: ${{ steps.budget.outputs.budget_kb }}
- BUDGET_ENTRY_FILE: ${{ steps.budget.outputs.entry_file }}
- BUDGET_CLOSURE_STATUS: ${{ steps.budget.outputs.closure_status }}
- BUDGET_CLOSURE_GZIP_KB: ${{ steps.budget.outputs.closure_gzip_kb }}
- BUDGET_CLOSURE_BUDGET_KB: ${{ steps.budget.outputs.closure_budget_kb }}
- BUDGET_CLOSURE_CHUNKS: ${{ steps.budget.outputs.closure_chunks }}
- # The per-chunk (objectui#5490) and sensitivity (objectui#5924) halves.
- # The step's exit code folds all three halves into one `budget_status`,
- # so without these the comment can say a budget objected but not which
- # half did — the reader has to open the job log to find out.
- BUDGET_CLOSURE_CHUNK_STATUS: ${{ steps.budget.outputs.closure_chunk_status }}
- BUDGET_CLOSURE_MEMBERSHIP_STATUS: ${{ steps.budget.outputs.closure_membership_status }}
- BUDGET_CLOSURE_HEADROOM_STATUS: ${{ steps.budget.outputs.closure_headroom_status }}
- # The freshness half (objectui#6245). Empty on a run it does not apply
- # to; the renderer filters an empty half out rather than rendering it.
- BUDGET_CLOSURE_FRESHNESS_STATUS: ${{ steps.budget.outputs.closure_freshness_status }}
- BUDGET_STEP_OUTCOME: ${{ steps.budget.outcome }}
- BUILD_PACKAGES_OUTCOME: ${{ steps.build_packages.outcome }}
- run: node scripts/render-budget-comment.mjs > budget-comment.md
-
- - name: Comment PR with results
- if: ${{ github.event_name == 'pull_request' && !cancelled() }}
- continue-on-error: true
- uses: actions/github-script@v9
- with:
- # GitHub's API intermittently rejects valid run tokens with 401
- # (seen on PR #1627), so 401/403 must stay retryable here.
- retries: 3
- retry-exempt-status-codes: 400,404,422
- script: |
- const fs = require('fs');
-
- if (!fs.existsSync('budget-comment.md')) {
- core.warning('budget-comment.md was not rendered; skipping PR comment.');
- return;
- }
- const body = fs.readFileSync('budget-comment.md', 'utf8');
-
- try {
- await github.rest.issues.createComment({
- issue_number: context.issue.number,
- owner: context.repo.owner,
- repo: context.repo.repo,
- body
- });
- } catch (error) {
- core.warning(`Could not comment on PR: ${error.message}`);
- await core.summary.addRaw(body).write();
- }
-
- # ── What the cache replayed, and only `main` writes it (objectui#12276) ──
- # The `type-check` job's two bookkeeping steps (objectui#12222), for this
- # job's cache. The report reads the summary `Build packages` asked turbo
- # for and puts what the restored cache replayed, what re-ran and the
- # restored key on ONE `::notice` annotation, readable through the
- # check-runs API by anyone who cannot open this log. The prune deletes
- # every entry this run did not use, on `push` only -- the runs that save
- # -- so `main`'s entry is exactly the current build outputs; pull-request
- # runs restore and never save (see the save step below), because what
- # they would save is scoped to a ref no other pull request can read, and
- # every build-sized entry saved is one more a size-limited cache store
- # can evict `main`'s for.
- #
- # `push` alone: this workflow has no `schedule`. Measured on
- # objectui#12276 before relying on it: the push concurrency group here
- # is the ref, so a merge can cancel the previous merge's run, and 16 of
- # the 20 newest `main` push runs succeeded (3 cancelled, 1 still running)
- # -- the save lands on most merges that reach this workflow's paths.
- #
- # Bookkeeping after the last checking step, bounded and
- # `continue-on-error`. `timeout-minutes: 1` each is DERIVED FOR THIS
- # SITE: the script reads one run summary and lists one directory, and
- # measured 87 ms on a lint run's summary (objectui#12276); a minute is
- # several hundred times that, and with the save's 5 this job's
- # bookkeeping stays at 7 of its 25.
- - name: Report what the turbo cache replayed
- if: ${{ !cancelled() }}
- timeout-minutes: 1
- continue-on-error: true
- env:
- RESTORED_CACHE_KEY: ${{ steps.turbo-cache.outputs.cache-matched-key }}
- run: node scripts/report-turbo-cache.mjs
-
- - name: Prune the turbo cache to what this run used
- if: github.event_name == 'push'
- timeout-minutes: 1
- continue-on-error: true
- run: node scripts/report-turbo-cache.mjs --prune
-
- # The bookkeeping half of the split documented at the restore step above,
- # placed HERE — last in the job, after the budget verdict AND after the
- # comment that reports it — because that is exactly where the post phase
- # it replaces already ran. Nothing about which commits this gate accepts
- # or rejects moves with it.
- #
- # `timeout-minutes: 5` is DERIVED FOR THIS SITE, and landing on the same
- # number PR #7047 chose is a result rather than a copy — objectui#7048
- # fences inheriting that 5, so here is this site's own arithmetic. This
- # job's own save of this cache measured 1s on 2026-09-02 (`Post Turbo
- # Cache`, job 100096569559); the whole job was 4m07s. 5 minutes is 300x
- # the measured save — the same ratio #7047 sized its bound at, arrived at
- # from this job's own number.
- #
- # `continue-on-error: true` is the other half, and without it the bound
- # would only trade a `cancelled` gate for a red one. A cache that failed
- # to upload costs the next run some time; it says nothing whatsoever
- # about the bundle sizes under test, so it must not speak for them.
- #
- # Behaviour preserved, spelled out so the equivalence is checkable:
- # - `cache-hit != 'true'` reproduces the combined action's own "exact
- # hit on the primary key ⇒ do not save" skip. A `restore-keys` prefix
- # match leaves `cache-hit` false and the save still runs, exactly as
- # the combined action behaved.
- # - the condition names no status function, so the implicit `success()`
- # still applies — matching the combined action's `post-if: success()`.
- # - same `path` and same `key` as the restore step above.
- # One deliberate change (objectui#12276): only `push` saves, after the
- # prune step above; see there for why.
- - name: Save Turbo Cache
- if: >-
- steps.turbo-cache.outputs.cache-hit != 'true'
- && github.event_name == 'push'
- uses: actions/cache/save@v6
- timeout-minutes: 5
- continue-on-error: true
- with:
- path: .turbo/cache
- key: turbo-${{ runner.os }}-perf-${{ github.sha }}
-
- # The pinned @objectstack build, saved only when this job built it (a cache
- # miss) and whatever the verdict above was -- it does not depend on it.
- # Bookkeeping: bounded and non-fatal (objectui#6577, objectui#7048). The bound
- # is not yet derived from a measured save; re-derive it from this step's runs.
- - name: Save the pinned @objectstack build
- if: ${{ !cancelled() && steps.objectstack-build.outcome == 'success' && steps.objectstack-pin.outputs.cache-hit != 'true' }}
- continue-on-error: true
- timeout-minutes: 2
- uses: actions/cache/save@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
diff --git a/ui/.github/workflows/published-dist-gate.yml b/ui/.github/workflows/published-dist-gate.yml
deleted file mode 100644
index 3250a0b0abd..00000000000
--- a/ui/.github/workflows/published-dist-gate.yml
+++ /dev/null
@@ -1,121 +0,0 @@
-name: Published Dist Gate
-
-# The artifact-level half of objectui#4846: no published package's `dist/` may
-# contain tooling material (`__tests__/`, `__mocks__/`, `__benchmarks__/`,
-# `*.test.*`, `*.spec.*`, `*.bench.*`, `*.stories.*`). The gate itself, and the
-# reason a cheap static criterion is NOT usable, are documented at length in
-# scripts/check-published-dist-tooling.mjs.
-#
-# ── Why this is a scheduled workflow and NOT a pull-request job ──────────────
-#
-# The only criterion that does not produce false reds is a criterion over the
-# BUILT ARTIFACT, so the gate has to build all 39 published packages. This
-# repository deliberately has no per-PR full-repo build — `ci.yml`'s `Build &
-# E2E` builds `@object-ui/console` alone, and its `Type Check` gets only the
-# DEPENDENCY CLOSURE out of turbo's `dependsOn: ["^build"]`, which is why leaf
-# packages such as `@object-ui/plugin-designer` (one of the four that shipped
-# tooling material in objectui#4836) are never built there.
-#
-# The 2026-08-16 ruling on objectui#4846 (comment 5307574139) placed the gate on
-# the RELEASE path — `pnpm changeset:publish` runs it before a single tarball
-# reaches npm, so a red gate cancels the publish — and allowed this nightly run
-# in addition, so a regression surfaces the night it lands instead of waiting
-# for the next release. ⛔ Do not add `pull_request` here: that is precisely the
-# per-PR full-repo build the ruling rejected.
-# `scripts/__tests__/check-published-dist-tooling.test.ts` fails if the gate is
-# ever wired into a workflow that carries a `pull_request` trigger.
-#
-# This workflow is therefore an ALARM, not a merge blocker: it cannot make
-# anyone's pull request red, so a slow or flaky run costs a second look and
-# nothing else. The blocking copy is the one on the publish path.
-
-on:
- workflow_dispatch:
-
- # Nightly at 03:41 UTC. Off the top of the hour because GitHub's scheduled
- # queue is most congested — and most delayed — at :00, the same reasoning
- # `check-links.yml` records for its weekly sweep.
- schedule:
- - cron: '41 3 * * *'
-
- # Changes to the gate itself get a real run, because the gate's own unit tests
- # (which DO run per-PR, inside the ordinary vitest suite) exercise its
- # verdicts over synthetic file lists and cannot exercise a real build. Scoped
- # by `paths` to the gate and its wiring, so this stays off every other pull
- # request.
- push:
- branches: [main]
- paths:
- - 'scripts/check-published-dist-tooling.mjs'
- - '.github/workflows/published-dist-gate.yml'
-
-concurrency:
- group: published-dist-gate-${{ github.ref }}
- cancel-in-progress: true
-
-permissions:
- contents: read
-
-jobs:
- published-dist:
- name: Published Dist Tooling Scan
- runs-on: ubuntu-latest
- # A full build of 39 published packages plus one `npm pack --dry-run` per
- # package. Generous, because a timeout here reads as a gate failure and this
- # workflow blocks nobody.
- timeout-minutes: 60
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v7
- with:
- submodules: true
-
- - name: Enable Corepack and download the pinned pnpm
- run: bash scripts/ci-setup-pnpm.sh
-
- - name: Setup Node.js
- uses: actions/setup-node@v7
- with:
- node-version: '22.x'
- cache: 'pnpm'
-
- # objectui#12957: every @objectstack/* package resolves to the build of the
- # commit `.objectstack-sha` pins, read from node_modules/.cache/objectstack, so
- # it must be there before pnpm resolves anything. Restored by the pin and built
- # only on a miss (scripts/objectstack-pin.mjs); the save is this job's last step.
- - name: Restore the pinned @objectstack build
- id: objectstack-pin
- uses: actions/cache/restore@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
-
- - name: Build @objectstack at the pin (skips a restored build)
- id: objectstack-build
- run: node scripts/objectstack-pin.mjs build
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- # No `pnpm build` step before this one, on purpose. The gate runs the
- # build ITSELF and fails when it cannot, so that "there were no artifacts,
- # therefore nothing was wrong" can never be this workflow's verdict — the
- # vacuous shape objectui#4846 rejected by name. A published package that
- # contributes no build output is reported as `no-build-output`, not
- # skipped.
- - name: Build every published package and scan its tarball
- run: pnpm check:published-dist
-
- # The pinned @objectstack build, saved only when this job built it (a cache
- # miss) and whatever the verdict above was -- it does not depend on it.
- # Bookkeeping: bounded and non-fatal (objectui#6577, objectui#7048). The bound
- # is not yet derived from a measured save; re-derive it from this step's runs.
- - name: Save the pinned @objectstack build
- if: ${{ !cancelled() && steps.objectstack-build.outcome == 'success' && steps.objectstack-pin.outputs.cache-hit != 'true' }}
- continue-on-error: true
- timeout-minutes: 2
- uses: actions/cache/save@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
diff --git a/ui/.github/workflows/repo-gates.yml b/ui/.github/workflows/repo-gates.yml
deleted file mode 100644
index cee95e9f45f..00000000000
--- a/ui/.github/workflows/repo-gates.yml
+++ /dev/null
@@ -1,586 +0,0 @@
-name: Repo Gates
-
-# ── One workflow, one checkout, one setup (objectui#12214) ──────────────────
-#
-# This file is the fifteen one-check gate workflows that used to sit beside it,
-# folded into ONE job under the two-speed doctrine (objectstack#22998): each of
-# them paid its own runner, checkout and Node setup on every pull request, push
-# and queue build, to run a check that mostly takes seconds. Nothing was
-# weakened in the fold:
-#
-# - every check runs the same command, on the same events, with no path
-# filter, exactly as it did in its own file;
-# - every check keeps its name: each step is named ` — `, where
-# `` is the job name the check used to report under;
-# - every gate step runs `if: ${{ !cancelled() }}` (or on the install / build
-# it needs), so one red check never hides another -- the job is red when
-# any of them is, and the step list says which.
-#
-# ⚠️ What DID move: the check-run each gate used to produce. None of them was
-# in the merge queue's required set (`node scripts/check-required-check-set.mjs`
-# reads the live set; when this fold landed the ruleset named
-# `Governed Surface Queue Guard`, `Changeset Declaration` and
-# `Spec Main Shape Gate`, which is why those three workflows were NOT folded --
-# the last has since left the set by objectui#12240's ruling, and objectui#12352
-# deleted its workflow when the spec shape gate moved to objectstack), so the
-# queue is unchanged. The one in-tree reader
-# of the old names, `scripts/dependabot-merge-gate.mjs`, now waits for the one
-# context this job produces, `Repo Gates`.
-#
-# ── Why no `paths` and no `paths-ignore`, deliberately ──────────────────────
-#
-# The conclusion each folded header reached on its own, and which still holds
-# for all of them together: the change most likely to trip these gates is a
-# markdown-only, docs-only, skills-only or workflow-only change -- precisely the
-# shape `ci.yml` and `lint.yml` decide inside their jobs to skip, and the shape
-# their `push` triggers filter out. A gate living behind that switch "rebuilds
-# the hole it exists to close" (objectui#3448, objectui#3523, objectui#4381).
-# Their pin tests under `scripts/__tests__/` fail if a filter is ever added
-# here, and each fails too if a second workflow starts running its script --
-# one gate, one home, and this file is that home for all of them.
-#
-# ── Why the order is install-free gates, then install, then build ───────────
-#
-# Twelve of the gates read the checkout with `node:fs` and nothing else, so
-# they run BEFORE `pnpm install`, which is what keeps them inside the
-# population `scripts/check-pre-install-import-graph.mjs` derives and holds to
-# an import graph free of `node_modules` -- the reason they were cheap enough
-# to run unfiltered in the first place. Keep any check added to that group
-# install-free.
-#
-# Three gates judge the PUBLISHED type surface and need built `dist/*.d.ts`:
-# the README export check builds every package under `packages/`, and the two
-# snippet gates each derive the closure they need with `--build-filter`. One
-# build serves all three: the union of `./packages/*` and both derived
-# filters. ⛔ It is never an unfiltered `turbo run build` -- the 2026-08-16
-# ruling on objectui#4846 (recorded in `published-dist-gate.yml`) rejected a
-# per-PR build of the whole workspace -- and a derived filter that names no
-# package is refused at the step that derives it.
-#
-# ⛔ No population count is written in this header, and none may be added
-# (objectui#7448): each gate prints its own census in its verdict line, and the
-# pin tests that read this header fail if a count of documents, pages or files
-# reappears here.
-
-on:
- pull_request:
- branches: [main, develop]
- push:
- branches: [main, develop]
- # Merge queue (objectui#3523 — see `ci.yml`'s trigger block for the full note
- # and the measurements behind it). A required check that does not report on a
- # queue build stalls the queue until the ruleset's 60-minute timeout fails it,
- # so an unfiltered gate that can become required subscribes here from the
- # start. `types:` is named although `checks_requested` is currently the only
- # activity type GitHub defines for `merge_group`.
- merge_group:
- types: [checks_requested]
- workflow_dispatch:
-
-concurrency:
- group: repo-gates-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
-
-permissions:
- contents: read
-
-jobs:
- repo-gates:
- name: Repo Gates
- runs-on: ubuntu-latest
- # The three gates that build used to carry 25 minutes each, in parallel;
- # here they share one install and one build, after the install-free gates.
- timeout-minutes: 30
-
- steps:
- # `submodules: true` is carried over from the README export check's own
- # checkout, unchanged. The tree declares no submodule today (no
- # `.gitmodules`), so it fetches nothing; it is kept so the fold changes
- # no gate's inputs, not because any gate here needs it.
- - name: Checkout code
- uses: actions/checkout@v7
- with:
- submodules: true
-
- # pnpm is on the path before any gate runs: `Docs Route Eager Closure
- # Check` calls its gate through `pnpm check:docs-route-closure`, with no
- # install.
- - name: Enable Corepack and download the pinned pnpm
- run: bash scripts/ci-setup-pnpm.sh
-
- - name: Setup Node.js
- uses: actions/setup-node@v7
- with:
- node-version: '22.x'
- cache: 'pnpm'
-
- # ── The install-free gates ───────────────────────────────────────────
- # A single raw U+0000 makes grep and ripgrep classify the whole file as
- # binary and print no matching lines at all, so the file drops out of code
- # search and out of every grep-based lint — with no error to say so. git
- # will not warn either: it decides binary-ness from the first 8000 bytes
- # only. Reads `git ls-files`, so no install is required.
- - name: Control Byte Scan — Scan tracked text files for raw control bytes
- if: ${{ !cancelled() }}
- run: node scripts/check-control-bytes.mjs
-
- # Reads `.github/workflows/` off the checkout, so no install is required.
- # The gate refuses to report a verdict at all if its own census collapses:
- # a scan that stops finding references renders identically to a clean
- # repository, which is the one way a check like this lies.
- - name: Action Ref Convention — Check action reference spelling against the declared convention
- if: ${{ !cancelled() }}
- run: node scripts/check-action-ref-convention.mjs
-
- # Two legs, in this order and in one step, for the reason `lint.yml` runs
- # the entry-guard gate the same way: a scan whose recogniser is broken
- # reports a clean tree. The self-test pins the workflow parser and the
- # graph walk against fixtures, so a green scan below means something.
- #
- # This step is itself in the population the gate derives — it runs a
- # `scripts/` file before this job installs anything — so
- # the gate walks its own import graph on every run. That is deliberate: a
- # floor that exempted its own enforcer would be the first thing to rot.
- - name: Pre-Install Import Graph Check — Check the pre-install gates' import graphs
- if: ${{ !cancelled() }}
- run: |
- node scripts/check-pre-install-import-graph.mjs --self-test
- node scripts/check-pre-install-import-graph.mjs
-
- # objectui#5150: the `git commit -F -` example in AGENTS.md §9 shipped with
- # its heredoc terminator wrapped in the single-quote-inside-single-quote
- # shell escape. Copied verbatim it does not fail with a message — it HANGS,
- # on a terminator that never matches, and a reader does not attribute a hung
- # terminal to the document. objectui#5151 measured the full derived gate
- # union against the replanted bytes: control-bytes, doc-links and both
- # changeset gates all exited 0, none of them negligently — the residue is
- # printable ASCII inside a code block and no scan surface reached it.
- #
- # ⛔ This gate checks an ENUMERATED LITERAL. It does NOT make fenced shell
- # examples executable-by-construction, and nothing in this repository does;
- # `bash -n` per block is objectui#5151's unbuilt "direction 1". The script
- # header states the boundary and the test suite asserts it as a fact.
- #
- # GREEN AT REST — there is nothing to find on an ordinary day — so it prints
- # its per-root census rather than a bare "OK", and it FAILS when a scan root
- # does not resolve or the population collapses. A scan that silently finds
- # nothing reads as coverage, which is this gate's own defect one level up.
- - name: Shell Escape Residue Scan — Check for machine-produced shell-escape residue in fenced examples
- if: ${{ !cancelled() }}
- run: node scripts/check-shell-escape-residue.mjs
-
- # A `vi.mock` whose relative specifier resolves to no file does NOT error.
- # Vitest registers the mock against a module id nothing imports, the run
- # proceeds with the real module everywhere, and the suite passes —
- # identically to a correct one, with no warning and no smaller assertion
- # count. objectui#5646: the one known instance (PR #5645) passed even with
- # the code under test reverted to the shape the suite was written to
- # catch, and only an ablation leg exposed it.
- #
- # This gate is GREEN AT REST — there are zero unresolvable specifiers in
- # the tree and there should stay zero — so it prints its census rather
- # than a bare "OK", and it FAILS if the population collapses to nothing.
- # A scan that silently finds nothing reads as coverage, which is this
- # gate's own defect one level up.
- - name: Inert vi.mock Specifier Check — Check every relative vi.mock specifier resolves
- if: ${{ !cancelled() }}
- run: node scripts/check-vi-mock-specifiers.mjs
-
- # The sibling property of the same call sites: a factory that HAND-LISTS
- # the exports it returns freezes the mock's export surface. The next
- # export any module in the file's import graph reads AT MODULE SCOPE then
- # kills the file during COLLECTION -- `Test Files 3 failed | 546 passed`
- # with `Tests 6694 passed`, ZERO failed assertions, because the tests in
- # those files never ran (objectui#6768). It reads as flake to whoever
- # sees it next, and the bill lands on whoever added the export.
- #
- # The recogniser is SEMANTIC, never a grep for `importOriginal`: that
- # spelling mis-counted eleven correct files as broken AND missed one
- # broken file entirely (objectui#6849). It asks whether the factory
- # OBTAINS the real module -- a callback parameter under any name, or
- # `vi.importActual` of the same specifier -- and SPREADS it.
- - name: Inert vi.mock Specifier Check — Check every covered vi.mock factory inherits the real export surface
- if: ${{ !cancelled() }}
- run: node scripts/check-vi-mock-inherit.mjs
-
- # Resolves every relative / `/docs/...` markdown link found in
- # `content/docs/**` against the files actually on disk. Reads the checkout
- # and nothing else, so no install is required. External URLs are a
- # different problem with a different tool — Lychee, in `check-links.yml`,
- # which sweeps both `content/docs/` and `docs/` on a weekly cron and on
- # demand, and deliberately gates nothing (#3449 fixed its scope; #3213
- # decided it stays off pull requests).
- - name: Internal Docs Link Check — Check internal docs links
- if: ${{ !cancelled() }}
- run: node scripts/check-doc-links.mjs
-
- # `check-doc-snippet-types` compiles `ts` / `tsx` / `typescript` fences and
- # nothing else, so a TypeScript block fenced any other way is invisible to
- # it — objectui#5867, whose remediation lane collected its population from
- # ```plaintext fences only. `plaintext` is not the only spelling of an
- # unhighlighted fence: objectui#6135 measured a ```text block opening
- # `interface FileUploadSchema {` sitting outside the gate AND outside the
- # lane that exists to close it, for no reason but how its fence is spelled.
- #
- # This gate reads block BODIES with objectui#5867's own triage classifier,
- # so no list of languages is on the enforcement path and `txt`, `console`,
- # `raw` or a bare fence with no info string at all cannot reopen the gap.
- # Today's residue is declared, SHRINK-ONLY, and is that lane's remaining
- # population per file.
- #
- # `--self-test` runs FIRST and is the half that stops the gate rotting into
- # decoration: it drives the real scanner over fixture sources, including
- # the three spellings named on objectui#6135, and pins the shrink-only
- # baseline in every direction it can move. A scanner whose recogniser is
- # broken reports a clean tree.
- - name: Doc Fence Language Check — Check that no fence spelling hides a TypeScript block
- if: ${{ !cancelled() }}
- run: |
- node scripts/check-doc-fence-languages.mjs --self-test
- node scripts/check-doc-fence-languages.mjs
-
- # A `` in a docs page resolves through
- # `getExample(id)`, which THROWS on an unknown id rather than degrading —
- # the honest runtime behaviour, and the reason a mistyped or stale id is a
- # crash in the published docs rather than a blank tile. Nothing asked the
- # question before render: the catalog's own suite resolves every registry
- # ENTRY, which cannot see a page pointing at an id that is not there.
- # Reads the checkout and nothing else, so no install.
- - name: Doc Example Id Check — Check documented example ids against the catalog registry
- if: ${{ !cancelled() }}
- run: node scripts/check-doc-example-ids.mjs
-
- # A `type` string in a `content/docs/**` code block is not rendered,
- # not parsed and not compared against anything, so a snippet can name a
- # component that does not exist and every check in the repo stays green —
- # while a reader who copies it gets the renderer's red "Unknown component
- # type" panel (OBJUI-001). That defect landed three times before this gate
- # (objectui#4786 `stats-card`, objectui#4796 `plugin:grid` and
- # `plugin:map`), each found by a human probe. The catalog side has had the
- # equivalent ratchet since objectui#4616
- # (`examples/schema-catalog/test/catalog-gallery-render.test.tsx`); this is
- # the missing half. Reads the checkout and nothing else, so no install.
- - name: Doc Component Type Check — Check documented component types against the registry
- if: ${{ !cancelled() }}
- run: node scripts/check-doc-component-types.mjs
-
- # Same shape, same blind spot, one file further out: `.github/prompts/**`
- # is markdown, so `ci.yml`'s type-check diff excludes it under `'**/*.md'`
- # and a prompt-only PR starts that gate nowhere. The step above asks "does
- # this type exist"; this one asks the stricter question a file read by an
- # AI AUTHOR has to survive — "does it render, or is it answered only by the
- # opt-in placeholder panel". A retired key passes the first and fails the
- # second, which is the whole of objectui#8929. Reads the checkout only.
- - name: Doc Component Type Check — Check prompt-taught component keys render
- if: ${{ !cancelled() }}
- run: node scripts/check-prompt-component-keys.mjs
-
- # The guides under `skills/` are a direct input to every agent that writes
- # code here, and their prose gives in-repo paths as coordinates. A dead one
- # produces no compile error — just "file not found" from a Read, and a
- # wasted lap re-locating a symbol that does exist (#3713 and #3730 were
- # 13+ of these in one guide, both rounds found by eye). Reads the checkout
- # and nothing else, so no install is required.
- - name: Skill Guide Path Check — Check paths stated in the skill guides
- if: ${{ !cancelled() }}
- run: node scripts/check-skills-paths.mjs
-
- # The self-test runs FIRST. A probe that cannot fail is not a probe: it
- # plants an untaught token that must go red, a substring-only match that
- # must go red, a `must_not_contain` entry that must NOT, and a baseline row
- # that must suppress exactly its own row — so a gate broken into permanent
- # green is caught here rather than by nobody.
- - name: Skill Eval Token Check — Self-test the oracle and the whole-token rule, both directions
- if: ${{ !cancelled() }}
- run: node scripts/check-skill-eval-tokens.mjs --self-test
-
- # Reads the checkout and nothing else, so no install is required.
- - name: Skill Eval Token Check — Check every eval must_contain token against the guides
- if: ${{ !cancelled() }}
- run: node scripts/check-skill-eval-tokens.mjs
-
- # Reads source only — no `node_modules`, no `dist`, no `.source`. The three
- # halves (declarations, ledger, gauge) are all printed before any of them
- # decides the exit code: exit 1 is a verdict about the registrar, exit 2
- # says the gauge itself is not trustworthy and is never reported as the
- # former.
- - name: Docs Route Eager Closure Check — Weigh the docs route eager closure
- if: ${{ !cancelled() }}
- run: pnpm check:docs-route-closure
-
- # objectui#12957: every @objectstack/* package resolves to the build of the
- # commit `.objectstack-sha` pins, read from node_modules/.cache/objectstack, so
- # it must be there before pnpm resolves anything. Restored by the pin and built
- # only on a miss (scripts/objectstack-pin.mjs); the save is this job's last step.
- - name: Restore the pinned @objectstack build
- id: objectstack-pin
- if: ${{ !cancelled() }}
- uses: actions/cache/restore@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
-
- - name: Build @objectstack at the pin (skips a restored build)
- id: objectstack-build
- if: ${{ !cancelled() }}
- run: node scripts/objectstack-pin.mjs build
-
- # ── The gates that read the built type surface ───────────────────────
- - name: Install dependencies
- id: install
- if: ${{ !cancelled() }}
- run: pnpm install --frozen-lockfile
-
- # The filter comes from the gate, so it can never drift from what the
- # marked fences import.
- #
- # ⛔ Do not fold this back into `echo "args=$(node …)" >> "$GITHUB_OUTPUT"`
- # (objectui#6221). A command substitution contributes its STDOUT to the
- # surrounding word and nothing else — the step's status is `echo`'s — so a
- # gate that failed reads as a gate that named no packages, `args` is
- # silently empty, and the build that reads it judges a closure nobody
- # derived -- with no signal anywhere. Capture the status, then write the output.
- - name: Skill Example Check — Derive the packages the marked examples import
- id: skill-filter
- if: ${{ !cancelled() && steps.install.outcome == 'success' }}
- run: |
- status=0
- args="$(node scripts/check-skill-examples.mjs --build-filter)" || status=$?
- if [ "$status" -ne 0 ]; then
- echo "::error::Could not derive the build filter: \`node scripts/check-skill-examples.mjs --build-filter\` exited $status. Refusing to continue — carrying on would build every package in the workspace instead of the ones the marked examples import." >&2
- exit "$status"
- fi
- case "$args" in
- *--filter=*) ;;
- *)
- echo "::error::The derived build filter names no package (got: '$args'). Refusing to continue -- see this workflow's header." >&2
- exit 1
- ;;
- esac
- echo "args=$args" >> "$GITHUB_OUTPUT"
-
- # The filter comes from the gate, so it can never drift from what the
- # covered documents import. A package the snippets need but nothing built
- # is reported by the gate as `unbuilt-package` — its own failure reason,
- # never as a page full of broken imports.
- #
- # ⛔ Do not fold this back into `echo "args=$(node …)" >> "$GITHUB_OUTPUT"`
- # (objectui#6221). A command substitution contributes its STDOUT to the
- # surrounding word and nothing else — the step's status is `echo`'s — so a
- # gate that failed reads as a gate that named no packages, `args` is
- # silently empty, and the build that reads it judges a closure nobody
- # derived -- with no signal anywhere. `set -o pipefail` is not the remedy and would
- # not help; there is no pipe here. Capture the status, then write the
- # output.
- - name: Doc Snippet Type Check — Derive the packages the covered snippets import
- id: snippet-filter
- if: ${{ !cancelled() && steps.install.outcome == 'success' }}
- run: |
- status=0
- args="$(node scripts/check-doc-snippet-types.mjs --build-filter)" || status=$?
- if [ "$status" -ne 0 ]; then
- echo "::error::Could not derive the build filter: \`node scripts/check-doc-snippet-types.mjs --build-filter\` exited $status. Refusing to continue — carrying on would build every package in the workspace instead of the ones the covered snippets import." >&2
- exit "$status"
- fi
- case "$args" in
- *--filter=*) ;;
- *)
- echo "::error::The derived build filter names no package (got: '$args'). Refusing to continue -- see this workflow's header." >&2
- exit 1
- ;;
- esac
- echo "args=$args" >> "$GITHUB_OUTPUT"
-
- # One build for the three gates below: every package under `packages/`
- # (the README export check reads each package's declared type entry), in
- # union with the two closures the snippet gates derived above, so a
- # derived filter that ever names a package outside `packages/` is still
- # built. The derived filters are LISTS of `--filter=` words and must
- # word-split, so they stay unquoted. A derive step that failed contributes
- # nothing here and is red on its own; its gate then reports `unbuilt`
- # rather than judging a tree it cannot see.
- #
- # ── The build's turbo cache: this job's own, and only `main` writes it ──
- # (objectui#13348) Until this step the build above ran cold on every
- # pull request, push and queue build. The shape is `performance-budget.yml`'s
- # `bundle-analysis` cache (objectui#12276), which `report-turbo-cache.test.ts`
- # holds every `.turbo/cache` in `.github/workflows/` to: ONE restore key,
- # this job's own `repo-gates` prefix, so no other job's entry is ever
- # restored here; split restore/save (objectui#6577, objectui#7048), the
- # restore UNBOUNDED for the reason written there; pull-request and queue
- # runs restore and never save, so on a pull request's ref this key finds
- # nothing and falls through to `main`. The report, the prune and the save
- # are this job's last steps.
- #
- # It changes what the build REPLAYS, never what it builds: the filter set
- # on the build step is untouched, and the header's "never an unfiltered
- # `turbo run build`" holds as before. Gated on the install like the build
- # it serves, so a run that will not build does not spend a restore.
- - name: Restore Turbo Cache
- id: turbo-cache
- if: ${{ !cancelled() && steps.install.outcome == 'success' }}
- uses: actions/cache/restore@v6
- with:
- path: .turbo/cache
- key: turbo-${{ runner.os }}-repo-gates-${{ github.sha }}
- restore-keys: |
- turbo-${{ runner.os }}-repo-gates-
-
- # `TURBO_RUN_SUMMARY` makes turbo write its run summary to `.turbo/runs/`;
- # it changes nothing turbo runs or caches, and `Report what the turbo
- # cache replayed` below reads it (objectui#12276).
- - name: Build every package the gates below read
- id: build
- if: ${{ !cancelled() && steps.install.outcome == 'success' }}
- env:
- SKILL_FILTER: ${{ steps.skill-filter.outputs.args }}
- SNIPPET_FILTER: ${{ steps.snippet-filter.outputs.args }}
- TURBO_RUN_SUMMARY: 'true'
- run: pnpm exec turbo run build --filter='./packages/*' $SKILL_FILTER $SNIPPET_FILTER
-
- # The self-test runs BEFORE the corpus, and after the build because its
- # compiler legs need the same built tree. A probe that cannot fail is not a
- # probe: it plants a fence that must go red and a marker that must be
- # reported, so a harness broken into permanent green is caught here rather
- # than by nobody.
- - name: Skill Example Check — Self-test the marker convention, both directions
- if: ${{ !cancelled() && steps.build.outcome == 'success' }}
- run: node scripts/check-skill-examples.mjs --self-test
-
- - name: Skill Example Check — Check the marked skill examples against the built types
- if: ${{ !cancelled() && steps.build.outcome == 'success' }}
- run: node scripts/check-skill-examples.mjs
-
- # REPORT-ONLY (objectui#7864). Code a generator EMITS from a template
- # literal under `packages/*/src/**` is compiled by nothing: `tsc` sees a
- # string, `tsup` copies it through, and this gate's own scan surface stops
- # at the authored pages — `content/docs`, the per-app docs trees, the
- # package READMEs, the root `README.md` (objectui#7115), the top level of
- # the root `docs/` tree (objectui#7856 card 1) and, recursively,
- # `docs/adr/**` and `docs/audits/**` (objectui#7856 card 2, which brought
- # them in LEDGER-FIRST: they are records, so their blocks are declared on
- # `UNGATED_DOCS` rather than compiled). This
- # step censuses that class through the same `compileSnippets()` the doc
- # blocks go through, against the closure the step above just built.
- #
- # It runs BEFORE the blocking gate on purpose. The census is meant to be
- # re-read on every run, and placed after it the number would be skipped on
- # exactly the runs where the corpus moved. It exits 0 whatever it finds —
- # one known member (objectui#7472) is an OPEN card — and non-zero only when
- # the instrument itself is broken: a walk that collapsed, or a failed
- # harness control. A check that runs, goes green and looked at nothing is
- # the counterfeit the gate's own header is built against.
- - name: Doc Snippet Type Check — Census the code emitted from template literals (report-only)
- if: ${{ !cancelled() && steps.build.outcome == 'success' }}
- run: node scripts/check-doc-snippet-types.mjs --emit-census
-
- - name: Doc Snippet Type Check — Compile documentation snippets against the built types
- if: ${{ !cancelled() && steps.build.outcome == 'success' }}
- run: node scripts/check-doc-snippet-types.mjs
-
- # objectui#8757. Same corpus discipline as the gate above: a block that
- # cannot compile is either fixed or DECLARED in the script's
- # `UNGATED_EXAMPLES` ledger with a written reason — ⛔ never made green by
- # narrowing what the gate scans.
- - name: Doc Snippet Type Check — Compile JSDoc `@example` blocks against the built types
- if: ${{ !cancelled() && steps.build.outcome == 'success' }}
- run: node scripts/check-doc-example-types.mjs
-
- # objectui#5250 (the maintainer ruling's JSON-fence slice). Every `json` /
- # `jsonc` node document in `content/docs` is judged through the STRICT
- # authoring face — `StrictAnyComponentSchema`, read from the
- # `@object-ui/types` the build step above produced (the snippet gate's
- # closure carries it) — the face `objectui validate` judges with. A fence
- # that is deliberately not a complete node document is declared on the
- # line above it, and the declaration is re-judged on every run. Last of
- # the three, for the reason the step above gives: a newly wired blocking
- # gate must not mask an established one.
- - name: Doc Snippet Type Check — Judge JSON doc fences through the strict authoring face
- if: ${{ !cancelled() && steps.build.outcome == 'success' }}
- run: node scripts/check-doc-json-fences.mjs
-
- # A README in `packages//` teaching `import { X } from
- # '@object-ui/'` for an `X` the package does not export ships in the
- # npm tarball and gives the reader TS2305 or a TypeError. One manual sweep
- # (objectui#5043) found it in seven packages and recorded that as a LOWER
- # bound, because the method could only see single-line imports.
- #
- # GREEN AT REST — zero drift in the tree when this landed and there should
- # stay zero — so it prints its census rather than a bare "OK", and FAILS if
- # the population collapses to nothing.
- - name: README Export Check — Check every README self-import against the real export surface
- if: ${{ !cancelled() && steps.build.outcome == 'success' }}
- run: pnpm check:readme-exports
-
- # ── What the cache replayed, and only `main` writes it (objectui#13348) ──
- # `performance-budget.yml`'s two bookkeeping steps (objectui#12276), for
- # this job's cache. The report reads the summary the build step asked turbo
- # for and puts what the restored cache replayed, what re-ran and the
- # restored key on ONE `::notice` annotation, readable through the
- # check-runs API by anyone who cannot open this log. The prune deletes
- # every entry this run did not use, on `push` only -- the runs that save --
- # so `main`'s entry is exactly the current build outputs; pull-request and
- # queue runs restore and never save, because what they would save is
- # scoped to a ref no other pull request can read, and every build-sized
- # entry saved is one more a size-limited cache store can evict `main`'s for.
- #
- # `push` alone: this workflow has no `schedule`. Read before relying on it
- # (objectui#13348): the push concurrency group here is the ref with
- # `cancel-in-progress`, so a merge can cancel the previous merge's run, and
- # 18 of the 20 newest `main` push runs succeeded (2 cancelled) -- the save
- # lands on most merges.
- #
- # Bookkeeping after the last checking step, bounded and
- # `continue-on-error`. `timeout-minutes: 1` each: the script reads one run
- # summary and lists one directory (87 ms measured on objectui#12276); a
- # minute is several hundred times that.
- - name: Report what the turbo cache replayed
- if: ${{ !cancelled() }}
- timeout-minutes: 1
- continue-on-error: true
- env:
- RESTORED_CACHE_KEY: ${{ steps.turbo-cache.outputs.cache-matched-key }}
- run: node scripts/report-turbo-cache.mjs
-
- - name: Prune the turbo cache to what this run used
- if: github.event_name == 'push'
- timeout-minutes: 1
- continue-on-error: true
- run: node scripts/report-turbo-cache.mjs --prune
-
- # The bookkeeping half of the split documented at the restore step above,
- # placed after every checking step. `cache-hit != 'true'` skips an exact
- # primary-key hit; a `restore-keys` prefix match leaves it false and the
- # save runs; the implicit `success()` applies; same `path` and `key` as the
- # restore. Only `push` saves, after the prune above.
- #
- # `timeout-minutes: 5` is NOT yet derived from this site's own save: no run
- # of this job has saved this cache. What it rests on is the nearest
- # measured save of the same entry -- `bundle-analysis` builds the same
- # task set (`--filter='./packages/*'`; the two derived filters here add no
- # task on objectui#13348's base) and its save measured 1s (objectui#7048
- # arithmetic, `performance-budget.yml`). Re-derive it from this step's own
- # runs. With the pinned build's 2 below, this job's saves stay at 7 of 30.
- - name: Save Turbo Cache
- if: >-
- steps.turbo-cache.outputs.cache-hit != 'true'
- && github.event_name == 'push'
- uses: actions/cache/save@v6
- timeout-minutes: 5
- continue-on-error: true
- with:
- path: .turbo/cache
- key: turbo-${{ runner.os }}-repo-gates-${{ github.sha }}
-
- # The pinned @objectstack build, saved only when this job built it (a cache
- # miss) and whatever the verdict above was -- it does not depend on it.
- # Bookkeeping: bounded and non-fatal (objectui#6577, objectui#7048). The bound
- # is not yet derived from a measured save; re-derive it from this step's runs.
- - name: Save the pinned @objectstack build
- if: ${{ !cancelled() && steps.objectstack-build.outcome == 'success' && steps.objectstack-pin.outputs.cache-hit != 'true' }}
- continue-on-error: true
- timeout-minutes: 2
- uses: actions/cache/save@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
diff --git a/ui/.github/workflows/required-check-set-patrol.yml b/ui/.github/workflows/required-check-set-patrol.yml
deleted file mode 100644
index 54fce00174c..00000000000
--- a/ui/.github/workflows/required-check-set-patrol.yml
+++ /dev/null
@@ -1,177 +0,0 @@
-name: Required Check Set Patrol
-
-# The standing caller for `scripts/check-required-check-set.mjs`.
-#
-# ## What it watches for (objectui#9422)
-#
-# `main`'s merge queue requires a set of check contexts — which ones is this
-# patrol's reading, not a number written here. That set lives in GitHub
-# ruleset 11776024 — repository SETTINGS. Removing a member from it reds no
-# gate, fires no alarm, and leaves no trace in any diff a reviewer reads. The
-# one time this repository was in that state (objectui#3523) three pull requests
-# (#3503, #3510, #3516) merged with `Type Check` at `conclusion=failure` onto a
-# `main` that #3498 had left with a type error; #3505 hot-fixed the result.
-#
-# ⛔ This patrol only READS. It never enrols, removes or renames a required
-# context, and never edits the ruleset — one `GET` per run and nothing else.
-#
-# ## Why a SCHEDULE, and why NOT a pull-request check
-#
-# A gate that calls the GitHub API at pull-request time inherits three failure
-# modes that have nothing to do with its subject: a fork pull request's reduced
-# token, an offline or degraded API, and rate limits. Each turns into a red that
-# teaches seats to ignore this check — and a watchdog nobody reads is exactly
-# what this card is about (objectui#6596: a permanently red check trains
-# everyone to ignore red).
-#
-# ⛔ A `merge_group` leg was considered and rejected for a sharper reason: a
-# check that is not in the required set does not gate the queue, and putting
-# THIS one in the required set is circular — the gate watching the required set
-# would then be the thing whose silent removal it exists to detect. The set can
-# never be self-watching. That argues for an out-of-band clock, which is what
-# this is.
-#
-# The same reasoning as `merge-queue-head-patrol.yml` rules out a
-# `pull_request` leg here: every job of a `pull_request`-triggered workflow
-# produces a check run, and `scripts/dependabot-merge-gate.mjs` requires every
-# produced name to be classified in one of its three buckets. Adding a leg would
-# mean editing that gate's declaration from a card that does not hold it. What
-# replaces it: `node scripts/check-required-check-set.mjs --self-test` runs
-# offline on every pull request through
-# `scripts/__tests__/check-required-check-set.test.ts`, which also drives the
-# real CLI over a committed fixture of the live API response with one member
-# removed and asserts it exits 3. The live transport is proven by
-# `workflow_dispatch`, which needs no merge.
-#
-# ## ⭐ What watches THIS patrol — the honest answer, in four parts
-#
-# A scheduled watchdog that silently stops running is the same defect one level
-# up, so this is answered rather than assumed:
-#
-# 1. THE LOGIC is watched offline, on every pull request, by the test above —
-# including the ablation. A gate never observed failing is decoration.
-# 2. THE WIRING is watched in a diff. That test pins this file's existence, its
-# `schedule` trigger, its permissions and the script it calls, and
-# `scripts/__tests__/ci-cd-pipeline-doc.test.ts` independently requires
-# every workflow here to carry a section and an inventory row in
-# `content/docs/guide/ci-cd-pipeline.md`. Deleting the patrol, or quietly
-# removing its clock, fails a pull request.
-# 3. THE TRANSPORT is watched by the exit contract: a reading that could not be
-# taken is exit 2 and a RED job, never a green one. A token that lost the
-# scope, a moved endpoint or an API outage is loud.
-# 4. ⚠️ THE RESIDUAL, declared and NOT dissolved: GitHub can delay or drop
-# scheduled runs under load, and a repository admin can disable this
-# workflow in the Actions UI — neither is visible from inside the tree. That
-# is the same class as the gap this patrol closes, one level up, and it is
-# an open question on objectui#9422 rather than something this file pretends
-# to solve. Two things bound it, both measured rather than assumed:
-# · GitHub disables scheduled workflows after 60 days of repository
-# INACTIVITY. `origin/main` took 3392 commits in the last 60 days and
-# the longest gap between consecutive commits over the most recent 400
-# was 0.13 days (measured 2026-09-14), so that precondition is nowhere
-# near holding here.
-# · The reader is STATELESS — every run re-reads the full current
-# membership, never a delta or a cursor. A dropped tick therefore costs
-# detection LATENCY and never coverage: the next tick sees the same
-# breach.
-#
-# ## The cadence, and why it is not the sibling's
-#
-# Daily, not every fifteen minutes. `merge-queue-head-patrol.yml` runs 96 times
-# a day because its subject is a wedge whose remedy has a 60-minute window. This
-# subject is a settings edit: it has no window, it does not heal itself, and it
-# stays true until somebody changes it back — so a second read four minutes
-# later would read exactly the same thing. One run a day is one API call a day.
-# The minute is off the hour on purpose: scheduled workflows bunch at :00 and
-# are delayed under that load. 05:23 UTC is clear of every other cron in this
-# directory.
-
-on:
- schedule:
- - cron: '23 5 * * *'
- workflow_dispatch: {}
-
-# Least privilege, and less than either sibling patrol takes. The reading is a
-# GET against a public repository's own rules endpoint; this job writes nothing,
-# anywhere, so it asks for no `issues: write` and no `actions: read`.
-permissions:
- contents: read
-
-# One patrol at a time, and never cancelled: a scheduled run overlapping a
-# manual dispatch buys nothing, and cancelling one mid-read discards a reading.
-concurrency:
- group: required-check-set-patrol
- cancel-in-progress: false
-
-jobs:
- patrol:
- name: Required check set patrol
- runs-on: ubuntu-latest
- timeout-minutes: 5
- steps:
- - name: Checkout repository
- uses: actions/checkout@v7
-
- - name: Setup Node.js
- uses: actions/setup-node@v7
- with:
- node-version: '22'
-
- # No `pnpm install`: the patrol imports `scripts/invoked-as.mjs` and
- # nothing else, and reaches the API through global `fetch`. Installing the
- # workspace would buy nothing and would give a scheduled patrol a lockfile
- # it could fail on.
- - name: Read the merge queue's required-check set
- id: patrol
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GITHUB_REPOSITORY: ${{ github.repository }}
- # The branch the queue protects, taken from the repository rather than
- # typed: `GET /rules/branches/` answers `200 []` for a branch
- # no ruleset targets, and the patrol reports an empty answer as a
- # breach — so a hand-typed branch name would be a permanent red.
- QUEUE_BASE_BRANCH: ${{ github.event.repository.default_branch }}
- run: |
- set +e
- node scripts/check-required-check-set.mjs \
- > "$RUNNER_TEMP/patrol.md" 2> "$RUNNER_TEMP/patrol.err"
- code=$?
- set -e
- # Captured with NO pipe in between: `node … | tail` would report the
- # PIPE's status, and `tail` essentially never fails, so a breach (3),
- # a clean reading (0) and an unreadable one (2) would all read as 0 —
- # the exact class of silent pass this patrol exists to catch.
- echo "exit_code=$code" >> "$GITHUB_OUTPUT"
- cat "$RUNNER_TEMP/patrol.err" >&2 || true
-
- - name: Publish the reading to the run summary
- # Always: this IS the delivery. A drift in the WATCHED contexts is
- # reported here and deliberately does not fail the job — see the script's
- # header for the two tiers and the failure mode that split accepts.
- if: always()
- run: |
- {
- echo "### Required check set patrol — exit ${{ steps.patrol.outputs.exit_code }}"
- echo
- cat "$RUNNER_TEMP/patrol.md" 2>/dev/null || echo '_(no reading produced)_'
- echo
- echo 'stderr
'
- echo
- echo '```'
- cat "$RUNNER_TEMP/patrol.err" 2>/dev/null || true
- echo '```'
- echo
- echo ' '
- } >> "$GITHUB_STEP_SUMMARY"
-
- - name: Fail the run on a breach, or when the set could not be read
- # LAST, on purpose: the summary carries the reading BEFORE the job goes
- # red. Land the truth, then raise the alarm.
- if: steps.patrol.outputs.exit_code != '0'
- run: |
- if [ "${{ steps.patrol.outputs.exit_code }}" = "3" ]; then
- echo "::error::The merge queue's required-check set has LOST a pinned member — a check that is not required does not fail a queue build, so the queue will merge without it and nothing else will go red. The reading is in this run's summary."
- else
- echo "::error::check-required-check-set exited ${{ steps.patrol.outputs.exit_code }} — the required set was NOT read. This is not an intact-set reading."
- fi
- exit 1
diff --git a/ui/.github/workflows/shadcn-check.yml b/ui/.github/workflows/shadcn-check.yml
deleted file mode 100644
index 2cdc1295d3a..00000000000
--- a/ui/.github/workflows/shadcn-check.yml
+++ /dev/null
@@ -1,250 +0,0 @@
-name: Check Shadcn Components
-
-on:
- # Run weekly on Mondays at 9:00 AM UTC
- schedule:
- - cron: '0 9 * * 1'
-
- # Allow manual trigger
- workflow_dispatch:
-
-# The reporting step below opens (or comments on) a tracking issue, which the
-# default token cannot do unless it is asked for. Declared explicitly so an
-# org-wide tightening of the default workflow permissions cannot silently turn
-# the only alarm channel this workflow has back into a no-op.
-#
-# `actions: read` is what lets a run see the PREVIOUS runs' marker steps, which
-# is the cross-run state the consecutive-unreachability escalation is built on
-# (objectui#3586; the alternatives and their costs are in
-# `scripts/shadcn-check-report.mjs`, `readRegistryStreak`).
-permissions:
- contents: read
- issues: write
- actions: read
-
-jobs:
- check-components:
- name: Check for Shadcn Component Updates
- runs-on: ubuntu-latest
-
- # objectui#3586 gap ②. Without this the job inherits GitHub's 360-minute
- # default, and the online step is 46 SERIAL registry requests through
- # `fetchUrl` in `scripts/shadcn-sync.js`, which sets no socket timeout — so a
- # black-holed connection is bounded by nothing but this line. Six hours of a
- # runner, once a week, on a schedule nobody watches.
- #
- # The arithmetic, from measurement rather than taste:
- #
- # - all 30 runs in the API history completed in 19-49s wall clock; the
- # longest was 49s (2026-04-06). The most recent (31374857502) took 34s.
- # - inside it the online step took ~1.7s for all 46 components
- # (09:30:22.71 -> 09:30:24.03), i.e. ~37ms per serial request, with
- # `Registry: 0 cached, 46 fetched` and 0 errors.
- # - degraded-but-alive worst case, at 10s per request: 46 x 10s = 7.7min,
- # plus ~35s of checkout/install/upload overhead.
- # - `pnpm install --frozen-lockfile` took 7s on a pnpm store cache hit, and
- # all 30 observed runs hit it. A lockfile change misses; budget ~5min.
- #
- # 8.3min + 5min = ~13min of compound worst case. 20 gives it ~1.5x headroom,
- # is ~24x the longest run ever observed, and turns a hang from 6 hours into
- # 20 minutes.
- timeout-minutes: 20
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v7
- with:
- submodules: true
-
- - name: Enable Corepack and download the pinned pnpm
- run: bash scripts/ci-setup-pnpm.sh
-
- - name: Verify pnpm version
- run: pnpm --version
-
- - name: Setup Node.js
- uses: actions/setup-node@v7
- with:
- node-version: '22.x'
- cache: 'pnpm'
-
- # objectui#12957: every @objectstack/* package resolves to the build of the
- # commit `.objectstack-sha` pins, read from node_modules/.cache/objectstack, so
- # it must be there before pnpm resolves anything. Restored by the pin and built
- # only on a miss (scripts/objectstack-pin.mjs); the save is this job's last step.
- - name: Restore the pinned @objectstack build
- id: objectstack-pin
- uses: actions/cache/restore@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
-
- - name: Build @objectstack at the pin (skips a restored build)
- id: objectstack-build
- run: node scripts/objectstack-pin.mjs build
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- # objectui#3586 gap ③. This step used to carry `continue-on-error: true`,
- # on the reasoning that `component-analysis.js` has "no verdict here to
- # swallow" — true of the DRIFT verdict, false of the step itself. Its one
- # non-zero exit is an uncaught crash, and a crash on a weekly schedule
- # turned the job red where nobody looks: the same silent-failure shape as
- # the swallowed exit code, one step over.
- #
- # The exit code is captured explicitly instead and classified with the
- # check step's, into the SAME issue channel — not a second one, and not
- # behind `continue-on-error`, which is what made the first gap invisible.
- #
- # `2>&1` matters: the crash writes its stack to stderr, and the old
- # `> analysis.txt` sent only stdout to the file the issue body quotes. An
- # alarm that cannot show the error it is alarming about is half an alarm.
- - name: Analyze components (offline)
- id: analyze
- shell: bash
- run: |
- echo "Running offline component analysis..."
- set +e
- pnpm shadcn:analyze 2>&1 | tee analysis.ansi.txt
- status=${PIPESTATUS[0]}
- set -e
- echo "exit_code=$status" >> "$GITHUB_OUTPUT"
-
- # `pnpm shadcn:check` has carried a REAL exit code since #3455: it exits
- # non-zero for one reason only — a declared local patch that is missing
- # from the file on disk, or that no longer re-applies to current upstream
- # (`results.patchFailures > 0` in scripts/shadcn-sync.js). Ordinary drift
- # (outdated/modified) and an unreachable registry both stay exit 0 by
- # design, because that gate "must only ever accuse real drift".
- #
- # This step used to carry `continue-on-error: true`, which threw that code
- # away wholesale — and because the reporting step was gated on `failure()`,
- # which a tolerated step never produces, the issue-creation path below had
- # never once run (objectstack#5805). The code is captured explicitly here
- # instead, and classified by the step after this one: this workflow runs
- # weekly on a schedule, and a red run on a page nobody opens is not an
- # alarm — an issue in the triage queue is.
- - name: Check component status (online)
- id: check
- shell: bash
- run: |
- echo "Checking component status against Shadcn registry..."
- set +e
- pnpm shadcn:check 2>&1 | tee check.ansi.txt
- status=${PIPESTATUS[0]}
- set -e
- echo "exit_code=$status" >> "$GITHUB_OUTPUT"
-
- # Everything the two steps above produce is classified here, in
- # `scripts/shadcn-check-report.mjs` — ANSI stripping, #3497's three check
- # classes, the analyze class, the cross-run registry streak, the step
- # summary, the annotations, and the issue body. It lives in a file because
- # #3497's version lived in YAML and therefore could not be tested; it is
- # covered by `scripts/__tests__/shadcn-check-report.test.ts`.
- #
- # It exits 0 for every CLASSIFIED outcome, alarms included: the alarm is
- # the issue, not the job colour. It going red means the classifier itself
- # crashed, which is the one failure this mechanism cannot route into its
- # own channel — hence the unit tests.
- - name: Classify this run
- id: verdict
- env:
- ANALYZE_EXIT_CODE: ${{ steps.analyze.outputs.exit_code }}
- CHECK_EXIT_CODE: ${{ steps.check.outputs.exit_code }}
- GITHUB_TOKEN: ${{ github.token }}
- run: node scripts/shadcn-check-report.mjs
-
- # ── Cross-run state ─────────────────────────────────────────────────────
- # These two steps do nothing in this run. Their names and conclusions ARE
- # the state the NEXT run reads back through the Actions API, which is how
- # "the registry has been unreachable N runs running" can be known at all
- # without a database (objectui#3586 gap ①).
- #
- # Three-valued by construction, and that is the point: exactly one of them
- # succeeds in a run that reached a verdict, and NEITHER appears in a run
- # that died before it — so "we don't know" is distinguishable from
- # "reachable", and only "unreachable" extends the streak.
- #
- # The names are a contract with `MARKER_STEPS` in
- # `scripts/shadcn-check-report.mjs`; the pin test holds them equal, because
- # a rename here would silently reset the streak forever.
- - name: 'Cross-run marker: registry reachable'
- if: steps.verdict.outputs.registry_state == 'reachable'
- run: echo "Registry reachable in this run — the unreachable streak is broken here."
-
- - name: 'Cross-run marker: registry unreachable'
- if: steps.verdict.outputs.registry_state == 'unreachable'
- run: |
- echo "Registry unreachable in this run (${{ steps.verdict.outputs.registry_errors }} component(s))."
- echo "Consecutive unreachable runs: ${{ steps.verdict.outputs.registry_streak }}."
-
- - name: Upload analysis results
- uses: actions/upload-artifact@v7
- if: always()
- with:
- name: shadcn-analysis
- path: |
- analysis.txt
- check.txt
- retention-days: 30
-
- # Deliberately NOT `continue-on-error`: this step is the alarm. If it
- # cannot deliver (missing permission, API outage), the job must go red,
- # because a silently broken alarm channel is the bug this workflow was
- # just fixed for.
- #
- # One channel for every reason — patch failure, a check that could not run,
- # an analyze crash, a registry blind for `ESCALATION_THRESHOLD` runs, or a
- # cross-run read that failed. Same labels, same de-duplication. The body is
- # rendered by the classifier step into `alarm-issue.md`; this step only
- # delivers it.
- - name: Report check failure as an issue
- if: steps.verdict.outputs.alarm == 'true'
- uses: actions/github-script@v9
- env:
- ISSUE_TITLE: ${{ steps.verdict.outputs.issue_title }}
- with:
- script: |
- const fs = require('fs');
- const body = fs.readFileSync('alarm-issue.md', 'utf8');
-
- // Check if there's already an open issue
- const issues = await github.rest.issues.listForRepo({
- owner: context.repo.owner,
- repo: context.repo.repo,
- state: 'open',
- labels: 'shadcn-sync',
- });
-
- if (issues.data.length > 0) {
- // Update existing issue
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issues.data[0].number,
- body: '## Updated Check Results\n\n' + body,
- });
- } else {
- // Create new issue
- await github.rest.issues.create({
- owner: context.repo.owner,
- repo: context.repo.repo,
- title: process.env.ISSUE_TITLE,
- body: body,
- labels: ['maintenance', 'shadcn-sync', 'dependencies'],
- });
- }
-
- # The pinned @objectstack build, saved only when this job built it (a cache
- # miss) and whatever the verdict above was -- it does not depend on it.
- # Bookkeeping: bounded and non-fatal (objectui#6577, objectui#7048). The bound
- # is not yet derived from a measured save; re-derive it from this step's runs.
- - name: Save the pinned @objectstack build
- if: ${{ !cancelled() && steps.objectstack-build.outcome == 'success' && steps.objectstack-pin.outputs.cache-hit != 'true' }}
- continue-on-error: true
- timeout-minutes: 2
- uses: actions/cache/save@v6
- with:
- path: node_modules/.cache/objectstack
- key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }}
diff --git a/ui/.github/workflows/test-shard-timings-refresh.yml b/ui/.github/workflows/test-shard-timings-refresh.yml
deleted file mode 100644
index 4d0ed366884..00000000000
--- a/ui/.github/workflows/test-shard-timings-refresh.yml
+++ /dev/null
@@ -1,257 +0,0 @@
-# Regenerate scripts/test-shard-timings.json from main's full-battery runs,
-# weekly, and open the pull request (objectui#12208, ruling 6121816480, letter
-# B: "the timing file refreshed from `main`'s full-suite runs on a schedule").
-#
-# ## What the file is, and why it has a clock
-#
-# `scripts/test-shard-timings.json` is the per-file duration dataset both
-# `ci.yml` test matrices are split by (`scripts/test-shard-sequencer.mjs`,
-# wired as the root vitest config's `sequence.sequencer`). It goes stale in one
-# direction only: files are added and suites get slower while the committed
-# numbers stay put. A new file is weighed at the dataset's median, so a stale
-# dataset degrades to spreading files by count -- the old hash split's shape,
-# and visible in the aggregators' headroom readings -- but the drift is silent
-# unless something refreshes it. A timer is what refreshes it.
-#
-# ## Why a committed file and a pull request, not an artifact the shards fetch
-#
-# Every leg of a matrix computes the whole partition on its own and keeps its
-# bin, so every leg of one run must read the SAME weights. A committed file
-# guarantees that by construction: the legs share a checkout. Legs that each
-# fetched "the newest timings artifact" at their own start could straddle a
-# refresh, and two legs on different weights run some files twice and others
-# never, with every leg green. The committed file also makes a partition move a
-# reviewed, reproducible event -- `pnpm test --shard=3/16` on a laptop runs the
-# same files CI's leg 3 ran -- and the dataset is in the root config's import
-# closure, so the pull request that moves it runs the full suite on the new
-# split before it lands (`scripts/select-affected-tests.mjs`).
-#
-# ## What this workflow is not allowed to do
-#
-# ⛔ It never hand-edits a timing: every byte it commits came out of
-# `scripts/test-file-timings.mjs merge`.
-# ⛔ It never touches `ci.yml`: not the matrix widths, not a `timeout-minutes`.
-# The width a run's measured total implies is printed by the `Test` and
-# `Test (coverage)` aggregators on every run; moving a width is a reviewed
-# edit, not this workflow's.
-# ⛔ It never opens a pull request for a byte-identical file.
-#
-# ## Whose identity opens the pull request, and why (objectui#13349)
-#
-# GitHub starts no workflow run for an event raised by this run's
-# `GITHUB_TOKEN`. Until objectui#13349 the refresh pushed and opened with that
-# token, so every refresh pull request arrived with no CI and sat until a seat
-# or person pushed to its branch: #13028 waited from 2026-10-19 to 2026-10-23
-# while the shards ran 1.3-1.8x apart on a dataset missing 764 files.
-#
-# So the two acts that leave the runner -- the push and the pull request -- are
-# done as the fleet App (`objectstack-fleet[bot]`), whose events DO start
-# workflows: `actions/create-github-app-token` mints a token on this runner
-# from the organization variable `OS_FLEET_APP_ID` and the organization secret
-# `OS_FLEET_PRIVATE_KEY`, the same pair and action objectstack's
-# `fleet-write.yml` mints with. The token is narrowed to this one repository
-# and to the three grants below, exists only in the steps that use it, and is
-# revoked by the action's post step. The job's own `GITHUB_TOKEN` keeps only
-# the reads the selection and the downloads need.
-#
-# ⛔ Never a personal access token: objectui#5397's ruling keeps one out of
-# this repository's automation, and the fleet App is an installation, not
-# a person. The App-token route here is objectui#13349's ruling (Sales,
-# 2026-10-23), which reads #5397's rejection as scoped to the release
-# pull request, where an in-workflow validation was the alternative; here
-# the pull request's own full-suite run IS the validation.
-# ⛔ No fallback to `GITHUB_TOKEN`. A missing credential is a red run that
-# names it, never a quiet return to a pull request nobody's CI reads.
-#
-# The branch, `ci/test-shard-timings-refresh`, is this workflow's own and is
-# overwritten by each refresh, the same posture `changeset-release.yml` takes
-# with its version branch. A force-push moves it from last week's base to
-# today's `main`, across whatever workflow files landed in between, and GitHub
-# refuses an App token that moves a ref across a workflow-file change unless it
-# carries `workflows: write` -- the reason for that third grant; this workflow
-# writes no workflow file.
-#
-# ## Which runs it reads
-#
-# `scripts/test-file-timings.mjs select`: the newest completed `CI` runs on
-# `main` from the full battery's events (`push` and the hourly `schedule`,
-# objectui#12214) whose sixteen `Test (shard N/16)` legs all reported success
-# and whose sixteen `test-file-timings-N` artifacts are all present; at most
-# six, and a file keeps the median of the runs that ran it. When no run
-# qualifies -- before the first full battery has uploaded, or after a week of
-# red -- it prints NOT MEASURED, writes nothing and exits green, because
-# nothing failed; a NOT MEASURED that persists is the finding.
-#
-# The `--shards` it selects by is the `test` matrix's width, and it moves with
-# it: a run of another width names other legs (`Test (shard 1/12)` is not
-# `Test (shard 1/16)`), so it is never eligible, and a stale value here selects
-# nothing forever -- the silent NOT MEASURED above, not a red run. Right after
-# a widening, the old-width runs are skipped by name and the first full
-# battery at the new width that goes green is the first one taken; a refresh
-# that fires before it exists keeps the committed file, which the sequencer
-# reads per file and never by width.
-#
-# ## The pull-request leg
-#
-# A pull request that edits this workflow or the scripts it runs executes it
-# all -- the selection, the downloads, the regeneration -- and stops before
-# the two acts that leave the runner: the push and the pull request. It mints
-# no fleet token either, so a pull request's run never reads the App secret.
-#
-# The same three steps run only from `main`: a manual dispatch on any other
-# branch regenerates and prints, and pushes nothing, so the refresh branch is
-# only ever built from `main`'s tree.
-
-name: Test Shard Timings Refresh
-
-on:
- schedule:
- # Monday 06:47 UTC: after a weekend of hourly full-battery runs, and on a
- # minute no other workflow here is scheduled on.
- - cron: '47 6 * * 1'
- workflow_dispatch:
- pull_request:
- paths:
- - '.github/workflows/test-shard-timings-refresh.yml'
- - 'scripts/test-file-timings.mjs'
- - 'scripts/test-shard-partition.mjs'
-
-# Read-only at the top level; the one job widens to exactly what it writes.
-permissions:
- contents: read
-
-# One refresh per ref at a time: the newer inputs are the better ones, so an
-# in-flight run yields. Scoped by ref so two pull requests' dry runs do not
-# cancel each other.
-concurrency:
- group: test-shard-timings-refresh-${{ github.ref }}
- cancel-in-progress: true
-
-jobs:
- refresh:
- name: Regenerate the per-file test timings
- runs-on: ubuntu-latest
- timeout-minutes: 15
- # The push and the pull request go out on the fleet App token minted
- # below, so this run's own token only reads.
- permissions:
- contents: read # the checkout
- actions: read # list runs, read their jobs, download their timing artifacts
-
- steps:
- # No persisted credential: the one push goes out on the fleet token
- # (`gh auth setup-git` below), never on this run's `GITHUB_TOKEN`.
- - name: Checkout code
- uses: actions/checkout@v7
- with:
- persist-credentials: false
-
- # The selector and the generator are node builtins only: no pnpm, no
- # install.
- - name: Setup Node.js
- uses: actions/setup-node@v7
- with:
- node-version: '22.x'
-
- # Exit 3 is NOT MEASURED, not a failure: nothing was eligible, nothing is
- # written, and the job says so and stays green. Any other non-zero exit
- # is red. `|| status=$?` because this step runs under `bash -e`.
- - name: Choose the runs to measure from
- id: select
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- status=0
- node scripts/test-file-timings.mjs select --shards 16 --limit 6 > "$RUNNER_TEMP/runs.json" || status=$?
- echo "status=$status" >> "$GITHUB_OUTPUT"
- if [ "$status" -eq 3 ]; then
- echo "::warning title=Test shard timings::NOT MEASURED: no completed full-battery run on main has all sixteen test shards green and all sixteen timing artifacts. scripts/test-shard-timings.json is untouched."
- exit 0
- fi
- if [ "$status" -ne 0 ]; then
- exit "$status"
- fi
- cat "$RUNNER_TEMP/runs.json"
-
- - name: Download each run's per-file timings
- if: steps.select.outputs.status == '0'
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- mkdir -p "$RUNNER_TEMP/timings"
- node -e 'for (const r of JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))) console.log(r.id)' "$RUNNER_TEMP/runs.json" > "$RUNNER_TEMP/run-ids.txt"
- while read -r id; do
- gh run download "$id" --repo "$GITHUB_REPOSITORY" --pattern 'test-file-timings-*' --dir "$RUNNER_TEMP/timings/$id"
- done < "$RUNNER_TEMP/run-ids.txt"
-
- - name: Regenerate the dataset
- if: steps.select.outputs.status == '0'
- run: |
- node scripts/test-file-timings.mjs merge --runs-file "$RUNNER_TEMP/runs.json" \
- --out scripts/test-shard-timings.json --summary "$RUNNER_TEMP/summary.md" "$RUNNER_TEMP"/timings/*
- git diff --stat -- scripts/test-shard-timings.json
- cat "$RUNNER_TEMP/summary.md" >> "$GITHUB_STEP_SUMMARY"
-
- # Read as a boolean, never printed: a red run that names the missing
- # credential beats `create-github-app-token`'s own error, which does not.
- - name: Check the fleet App credential is visible to this repository
- id: credential
- if: steps.select.outputs.status == '0' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
- env:
- HAS_APP_ID: ${{ vars.OS_FLEET_APP_ID != '' }}
- HAS_PRIVATE_KEY: ${{ secrets.OS_FLEET_PRIVATE_KEY != '' }}
- run: |
- if [ "$HAS_APP_ID" != 'true' ] || [ "$HAS_PRIVATE_KEY" != 'true' ]; then
- echo "::error title=Test shard timings::The fleet App credential is not visible to this repository (OS_FLEET_APP_ID set: $HAS_APP_ID; OS_FLEET_PRIVATE_KEY set: $HAS_PRIVATE_KEY). Both are organization-level; grant this repository access to them. Nothing was pushed (objectui#13349)."
- exit 1
- fi
- # The token's one repository, by name: the action takes names
- # without the owner, and an empty list would widen it to every
- # repository the installation covers.
- echo "repository=${GITHUB_REPOSITORY#*/}" >> "$GITHUB_OUTPUT"
-
- - name: Mint the fleet App token, narrowed to this repository
- id: fleet
- if: steps.select.outputs.status == '0' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
- uses: actions/create-github-app-token@v3
- with:
- app-id: ${{ vars.OS_FLEET_APP_ID }}
- private-key: ${{ secrets.OS_FLEET_PRIVATE_KEY }}
- owner: ${{ github.repository_owner }}
- repositories: ${{ steps.credential.outputs.repository }}
- permission-contents: write # push this workflow's own branch
- permission-pull-requests: write # open or update the refresh pull request
- permission-workflows: write # the force-push crosses workflow files main changed (header)
-
- - name: Open or update the refresh pull request
- if: steps.select.outputs.status == '0' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
- env:
- GH_TOKEN: ${{ steps.fleet.outputs.token }}
- BRANCH: ci/test-shard-timings-refresh
- run: |
- if git diff --quiet -- scripts/test-shard-timings.json; then
- echo "The regenerated dataset is byte-identical to the committed one: no pull request."
- exit 0
- fi
- gh auth setup-git
- git config user.name 'objectstack-fleet[bot]'
- git config user.email '332303061+objectstack-fleet[bot]@users.noreply.github.com'
- git switch -c "$BRANCH"
- git add scripts/test-shard-timings.json
- git commit -m 'chore(test): refresh the per-file test timings (objectui#12208)'
- git push --force origin "$BRANCH"
- {
- echo 'The weekly refresh of `scripts/test-shard-timings.json`, the per-file durations both `ci.yml` test matrices are split by (objectui#12208). Generated by `scripts/test-file-timings.mjs merge` from the runs below; never hand-edited.'
- echo
- cat "$RUNNER_TEMP/summary.md"
- echo
- echo 'Pushed and opened as the fleet App (objectui#13349), so this pull request runs its own CI: the dataset is in the root vitest config import closure, so that run is the full suite on the new split.'
- } > "$RUNNER_TEMP/body.md"
- open=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$BRANCH" --state open --json number --jq '.[0].number // empty')
- if [ -n "$open" ]; then
- gh pr edit "$open" --repo "$GITHUB_REPOSITORY" --body-file "$RUNNER_TEMP/body.md"
- echo "Updated the open refresh pull request #$open."
- else
- gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$BRANCH" \
- --title 'chore(test): refresh the per-file test timings (objectui#12208)' --body-file "$RUNNER_TEMP/body.md"
- fi
diff --git a/ui/package.json b/ui/package.json
index 206648cfd0c..ba8f3337523 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -55,7 +55,6 @@
"census:changeset-polarity": "node scripts/changeset-polarity-census.mjs",
"check:new-line-citations": "node scripts/check-new-cross-file-line-citations.mjs",
"check:control-bytes": "node scripts/check-control-bytes.mjs",
- "check:action-ref-convention": "node scripts/check-action-ref-convention.mjs",
"check:published-dist": "node scripts/check-published-dist-tooling.mjs",
"check:published-tsconfig-exclude": "node scripts/check-published-tsconfig-tooling-exclude.mjs",
"check:i18n-keys": "node scripts/check-i18n-call-site-keys.mjs",
diff --git a/ui/scripts/__tests__/check-action-ref-convention.test.ts b/ui/scripts/__tests__/check-action-ref-convention.test.ts
deleted file mode 100644
index 59f565aea53..00000000000
--- a/ui/scripts/__tests__/check-action-ref-convention.test.ts
+++ /dev/null
@@ -1,337 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import fs from 'node:fs';
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-import {
- CONTROL_ACTION,
- DECLARED_EXCEPTIONS,
- DEFAULT_SPELLING,
- FLOORS,
- censusFailures,
- classify,
- listWorkflows,
- parseRefs,
- scan,
- workflowDir,
-} from '../check-action-ref-convention.mjs';
-
-/**
- * objectui#8465 — the convention for how this repository spells an action
- * reference, and the proof that the gate enforcing it can actually fail.
- *
- * The card's finding was a SOLE INSTANCE, not a spelling: one of 13 references
- * was written differently from the other twelve, and it was the only one that
- * had never resolved — 236 runs, 0 successes, eight months of silence. A gate
- * against that has exactly one job, and it is not "reject SHAs": it is to make
- * an off-convention reference impossible to add without writing down why.
- *
- * Which makes the red branch the entire value of this file. A gate over
- * `.github/workflows/**` is validated by nothing else in the repository — not
- * the type checker, not ESLint, not a schema — so if its scan silently stops
- * finding references it reports a clean tree forever, and that renders exactly
- * like success. Every mutation below is asserted to have APPLIED before its
- * result is read.
- */
-const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
-
-describe('the tree follows the convention today', () => {
- it('is green: no undeclared off-convention reference, no stale exception', () => {
- const result = scan(repoRoot);
- expect(
- result.offenders.map((o: { file: string; line: number; ref: string }) => `${o.file}:${o.line} ${o.ref}`),
- 'These action references are spelled differently from the repository default and nothing ' +
- 'says why. Either spell them as a floating major tag, or declare them in ' +
- 'DECLARED_EXCEPTIONS in scripts/check-action-ref-convention.mjs with a reason and an issue.',
- ).toEqual([]);
- expect(
- result.stale.map((e: { workflow: string; action: string }) => `${e.workflow} :: ${e.action}`),
- 'These DECLARED_EXCEPTIONS entries match no off-convention reference any more — delete them. ' +
- 'An escape hatch nobody removes is how a baseline turns into a permanent skip-list.',
- ).toEqual([]);
- });
-
- it('the census is trustworthy — floors met and the positive control found', () => {
- // A zero means nothing without a term known to be present in the same run.
- // The gate carries that control itself; this asserts it is doing so.
- const result = scan(repoRoot);
- expect(censusFailures(result)).toEqual([]);
- expect(result.census.controlPresent, `${CONTROL_ACTION} must appear in some workflow`).toBe(true);
- expect(result.census.workflowFiles).toBeGreaterThanOrEqual(FLOORS.workflowFiles);
- expect(result.census.distinctRefs).toBeGreaterThanOrEqual(FLOORS.distinctRefs);
- });
-
- it('reproduces the census independently of the gate parser', () => {
- // The gate's own count is not evidence for the gate. This reads the same
- // directory with a different method and requires the two to agree, so a
- // parser that quietly narrows its population is caught by the disagreement.
- const dir = workflowDir(repoRoot);
- const files = listWorkflows(dir);
- const naive = new Set();
- for (const file of files) {
- for (const line of fs.readFileSync(path.join(dir, file), 'utf8').split('\n')) {
- if (/^\s*#/.test(line)) continue;
- const m = /^\s*(?:-\s*)?uses:\s*([^\s#]+)/.exec(line);
- if (m) naive.add(m[1].replace(/^['"]|['"]$/g, ''));
- }
- }
- const fromGate = new Set(scan(repoRoot).distinct);
- expect([...naive].sort()).toEqual([...fromGate].sort());
- expect(naive.size).toBeGreaterThan(5);
- });
-});
-
-describe('the exception table stays honest', () => {
- it('every entry carries an issue and a real reason', () => {
- for (const entry of DECLARED_EXCEPTIONS as Array<{
- workflow: string;
- action: string;
- issue: string;
- reason: string;
- }>) {
- expect(entry.issue, `${entry.workflow} :: ${entry.action} must name the issue that owns it`).toMatch(
- /#\d+/,
- );
- expect(
- entry.reason.length,
- `DECLARED_EXCEPTIONS[${entry.workflow} :: ${entry.action}] must carry a real justification — ` +
- '"it was like that already" is the defect, not a reason',
- ).toBeGreaterThan(40);
- expect(
- fs.existsSync(path.join(workflowDir(repoRoot), entry.workflow)),
- `DECLARED_EXCEPTIONS names ${entry.workflow}, which is not a workflow file`,
- ).toBe(true);
- }
- });
-});
-
-describe('the gate can fail (non-vacuity)', () => {
- const dir = workflowDir(repoRoot);
-
- /** A scan over a throwaway copy of `.github/workflows/`, so nothing on disk moves. */
- function scanMutated(mutate: (files: Map) => void, exceptions = DECLARED_EXCEPTIONS) {
- const files = new Map();
- for (const file of listWorkflows(dir)) {
- files.set(file, fs.readFileSync(path.join(dir, file), 'utf8'));
- }
- const before = new Map(files);
- mutate(files);
- const changed = [...files].some(([name, text]) => before.get(name) !== text) || files.size !== before.size;
- expect(changed, 'mutation did not apply — the unmutated tree would have been scanned').toBe(true);
-
- const root = fs.mkdtempSync(path.join(repoRoot, 'node_modules', '.action-ref-convention-'));
- try {
- const target = path.join(root, '.github', 'workflows');
- fs.mkdirSync(target, { recursive: true });
- for (const [name, text] of files) fs.writeFileSync(path.join(target, name), text);
- return scan(root, { exceptions });
- } finally {
- fs.rmSync(root, { recursive: true, force: true });
- }
- }
-
- it('goes red on an undeclared SHA pin — the exact shape of objectui#8465', () => {
- const result = scanMutated((files) => {
- const name = 'repo-gates.yml';
- files.set(
- name,
- files.get(name)!.replace('uses: actions/checkout@v7', 'uses: actions/checkout@' + 'a'.repeat(40)),
- );
- });
- expect(result.offenders.map((o: { file: string; kind: string }) => `${o.file} ${o.kind}`)).toContain(
- 'repo-gates.yml sha',
- );
- });
-
- it('goes red on an undeclared floating BRANCH ref, not only on SHAs', () => {
- // The convention is a spelling, not an anti-SHA rule. `@main` is the other
- // direction of the same defect: a reference nothing around it looks like.
- const result = scanMutated((files) => {
- const name = 'repo-gates.yml';
- files.set(name, files.get(name)!.replace('uses: actions/checkout@v7', 'uses: actions/checkout@main'));
- });
- expect(result.offenders.map((o: { ref: string }) => o.ref)).toContain('actions/checkout@main');
- });
-
- it('goes red on a stale exception — an entry that matches nothing', () => {
- // This case used to mutate the real `stale.yml`, whose SHA pin was the first
- // entry DECLARED_EXCEPTIONS held. objectui#8548 deleted that workflow and the
- // entry with it, so the shape is reproduced over a synthetic entry — which is
- // stricter, not weaker: the assertion no longer depends on one particular
- // workflow surviving. The synthetic entry is APPENDED to the real table, so
- // the tree's own declared exceptions stay declared and the only entry under
- // test is this one, whatever the real table holds (objectui#11174 added one).
- //
- // Both halves are pinned, because only the pair says the entry is doing
- // work. An entry that never silences anything would satisfy the second half
- // on its own.
- const declared = [
- ...DECLARED_EXCEPTIONS,
- {
- workflow: 'repo-gates.yml',
- action: 'actions/checkout',
- issue: 'objectui#8465',
- reason: 'synthetic entry for the non-vacuity proof below — not a real exception',
- },
- ];
- const shaPin = (files: Map) => {
- const name = 'repo-gates.yml';
- files.set(
- name,
- files.get(name)!.replace('uses: actions/checkout@v7', 'uses: actions/checkout@' + 'a'.repeat(40)),
- );
- };
-
- // Matching: the ref is off-convention and the entry names it, so nothing is
- // reported from either direction.
- const matching = scanMutated(shaPin, declared);
- expect(matching.offenders).toEqual([]);
- expect(matching.stale).toEqual([]);
-
- // Orphaned: the workflow the entry names is deleted — literally what
- // objectui#8548 did to the real table. The entry now matches nothing and
- // must be reported, loudly, so that deleting a workflow cannot leave a
- // permanently red `main` behind it.
- const orphaned = scanMutated((files) => {
- shaPin(files);
- files.delete('repo-gates.yml');
- }, declared);
- expect(orphaned.offenders).toEqual([]);
- expect(orphaned.stale.map((e: { workflow: string }) => e.workflow)).toEqual(['repo-gates.yml']);
- });
-
- it('goes red when the census collapses, instead of reporting a clean tree', () => {
- const result = scanMutated((files) => {
- for (const name of [...files.keys()].slice(1)) files.delete(name);
- });
- // The offender list is empty here — which is precisely why the floors have
- // to be a separate, louder verdict. Without them this reads as success.
- expect(result.offenders).toEqual([]);
- expect(censusFailures(result).length).toBeGreaterThan(0);
- });
-
- it('a declared exception silences the offender, and only that one', () => {
- // Appended to the real table for the reason the stale-exception case gives:
- // the tree's own declared exceptions stay declared, so `fake` is the only
- // entry whose effect this case reads.
- const fake = [
- ...DECLARED_EXCEPTIONS,
- { workflow: 'repo-gates.yml', action: 'actions/checkout', issue: 'objectui#1', reason: 'x'.repeat(50) },
- ];
- // Two off-convention refs, in two different workflows, and `fake` names only
- // the first. The second is the whole point of the case: until objectui#8548
- // it was `stale.yml`'s real SHA pin, which the tree no longer carries, so it
- // is injected here instead. ⛔ Do not drop it and keep only the silenced
- // half — a gate that reported nothing at all would pass that alone.
- const result = scanMutated((files) => {
- files.set(
- 'repo-gates.yml',
- files.get('repo-gates.yml')!.replace('uses: actions/checkout@v7', 'uses: actions/checkout@main'),
- );
- files.set(
- 'shadcn-check.yml',
- files
- .get('shadcn-check.yml')!
- .replace('uses: actions/checkout@v7', 'uses: actions/checkout@' + 'd'.repeat(40)),
- );
- }, fake);
- // The ref `fake` covers is silenced...
- expect(result.offenders.map((o: { ref: string }) => o.ref)).not.toContain('actions/checkout@main');
- expect(result.stale).toEqual([]);
- // ...and ONLY that one: the second workflow's SHA is undeclared and is
- // reported. Proof that what silences a reference is an entry naming it, not
- // the gate being lax about off-convention spellings.
- expect(result.offenders.map((o: { file: string; kind: string }) => `${o.file} ${o.kind}`)).toEqual([
- 'shadcn-check.yml sha',
- ]);
- });
-});
-
-describe('prose cannot be mistaken for a reference', () => {
- it('ignores action refs written inside comments', () => {
- // objectui#8465's triage hit this: a `changesets/action@v1` followed by a
- // trailing backtick, inside prose, read as a 14th reference. Every workflow
- // in this repository discusses action refs in comments — this gate's own
- // workflow header does, at length.
- const source = [
- '# see `changesets/action@v1` and actions/stale@' + 'b'.repeat(40),
- ' # uses: actions/checkout@main',
- ' uses: actions/checkout@v7',
- ].join('\n');
- expect(parseRefs(source, 'sample.yml').map((r: { ref: string }) => r.ref)).toEqual([
- 'actions/checkout@v7',
- ]);
- });
-
- it("does not count this gate's own workflow header as references", () => {
- const source = fs.readFileSync(path.join(dirOf(), 'repo-gates.yml'), 'utf8');
- expect(parseRefs(source, 'repo-gates.yml').map((r: { ref: string }) => r.ref)).toEqual([
- 'actions/checkout@v7',
- 'actions/setup-node@v7',
- // In file order: the pinned @objectstack build's restore (objectui#12957),
- // the build's turbo cache restore and save (objectui#13348), then the
- // pinned build's save.
- 'actions/cache/restore@v6',
- 'actions/cache/restore@v6',
- 'actions/cache/save@v6',
- 'actions/cache/save@v6',
- ]);
- });
-
- function dirOf() {
- return workflowDir(repoRoot);
- }
-});
-
-describe('classification', () => {
- it('accepts the declared default and nothing looser', () => {
- expect(DEFAULT_SPELLING.test('v7')).toBe(true);
- expect(DEFAULT_SPELLING.test('v9')).toBe(true);
- // A full semver tag is NOT the default: `@v9.0.0` does not float, so it
- // carries the same "nothing else looks like this" exposure as a SHA.
- expect(DEFAULT_SPELLING.test('v9.0.0')).toBe(false);
- expect(classify('actions/cache/restore@v6').conforms).toBe(true);
- expect(classify('actions/stale@' + 'c'.repeat(40))).toMatchObject({ kind: 'sha', conforms: false });
- expect(classify('owner/action@v1.2.3')).toMatchObject({ kind: 'other', conforms: false });
- expect(classify('owner/action')).toMatchObject({ kind: 'unversioned', conforms: false });
- });
-
- it('exempts by shape the refs that carry no version to spell', () => {
- // Stated rather than left implicit: neither shape occurs in the tree today,
- // and an unexplained silent pass is the thing this gate exists to remove.
- expect(classify('./.github/actions/local')).toMatchObject({ kind: 'local', conforms: true });
- expect(classify('docker://alpine:3.20')).toMatchObject({ kind: 'docker', conforms: true });
- });
-});
-
-describe('the gate is wired into CI', () => {
- const workflow = path.join(workflowDir(repoRoot), 'repo-gates.yml');
-
- it('has a workflow that runs it', () => {
- expect(fs.existsSync(workflow), 'action-ref-convention.yml must exist').toBe(true);
- const text = fs.readFileSync(workflow, 'utf8');
- expect(text).toContain('node scripts/check-action-ref-convention.mjs');
- });
-
- it('runs on pull requests and on queue builds, with no path filter', () => {
- // A path filter here would be self-defeating in the one direction that
- // matters: the population this gate scans IS `.github/workflows/`, and
- // `ci.yml` / `lint.yml` skip their expensive steps on such a change.
- const text = fs.readFileSync(workflow, 'utf8');
- const on = text.slice(text.indexOf('\non:'));
- const uncommented = on
- .split('\n')
- .filter((line) => !/^\s*#/.test(line))
- .join('\n');
- expect(uncommented).toMatch(/^ {2}pull_request:/m);
- expect(uncommented).toMatch(/^ {2}merge_group:/m);
- expect(uncommented).not.toMatch(/paths(-ignore)?:/);
- });
-
- it('is reachable as a package script', () => {
- const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
- expect(pkg.scripts['check:action-ref-convention']).toBe(
- 'node scripts/check-action-ref-convention.mjs',
- );
- });
-});
diff --git a/ui/scripts/__tests__/check-governed-queue-guard.test.ts b/ui/scripts/__tests__/check-governed-queue-guard.test.ts
index 3a4c65f68e6..36553a1177a 100644
--- a/ui/scripts/__tests__/check-governed-queue-guard.test.ts
+++ b/ui/scripts/__tests__/check-governed-queue-guard.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { execFileSync } from 'node:child_process';
+import { execFileSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -13,6 +13,7 @@ import {
} from '../check-governed-queue-guard.mjs';
import { selfTestCases, stripAnsi } from './helpers/child-verdict';
+import { workspaceRoot } from '../workspace-root.mjs';
const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..');
const GATE = 'scripts/check-governed-queue-guard.mjs';
@@ -107,8 +108,17 @@ describe('the governed surface is the ruled definition, and it is real', () => {
]);
});
- it('every declared surface exists in this tree', () => {
- for (const surface of GOVERNED_SURFACES) {
+ /**
+ * The one row whose directory left this tree: objectstack#25712 deleted
+ * `ui/.github/workflows/`, which GitHub never ran under the monorepo. ⚠️ PENDING
+ * the maintainer's call on objectstack#25712, since the row is objectui#12255's
+ * ruling: drop it, keep it as below, or retire this register for the root one
+ * (sub-issue 4).
+ */
+ const MOVED = 'workflows';
+
+ it('every declared surface exists in this tree, the moved workflows row aside', () => {
+ for (const surface of GOVERNED_SURFACES.filter((s) => s.id !== MOVED)) {
// `in` rather than `surface.prefix ?? surface.exact`: the register is a
// union of frozen literals, so exactly one of the two keys exists on each
// member and neither exists on all of them. `tsconfig.scripts.json` infers
@@ -124,6 +134,25 @@ describe('the governed surface is the ruled definition, and it is real', () => {
}
});
+ it('the workflows row moved: the root register governs ui/.github/workflows/** at Tier H', () => {
+ // Self-retiring: once the directory is back, the case above must read it again.
+ expect(
+ fs.existsSync(path.join(ROOT, '.github/workflows')),
+ 'ui/.github/workflows/ exists again: fold the workflows row back into the existence case above',
+ ).toBe(false);
+ // The guard that runs is the root one, so its own predicate is asked, by path.
+ const probe = 'ui/.github/workflows/ci.yml';
+ const run = spawnSync('node', ['scripts/pm/check-governed-merges.mjs', '--test', probe, '--json'], {
+ cwd: workspaceRoot(),
+ encoding: 'utf8',
+ });
+ expect(run.stdout, run.stderr).toMatch(/^\{/);
+ const verdict = JSON.parse(run.stdout) as { governed: boolean; tier: string; matched: Array<{ id: string; files: string[] }> };
+ expect(verdict.governed).toBe(true);
+ expect(verdict.tier).toBe('H');
+ expect(verdict.matched.map((s) => [s.id, s.files])).toEqual([['ui-workflows', [probe]]]);
+ });
+
it('governs every workflow, its own included, and not the script that workflow runs', () => {
// This case used to pin the opposite ("does not govern its own workflow, or
// CI configuration generally") as the widening deliberately NOT taken. The
diff --git a/ui/scripts/__tests__/check-pre-install-import-graph.test.ts b/ui/scripts/__tests__/check-pre-install-import-graph.test.ts
index 024010e3787..50ae21893ea 100644
--- a/ui/scripts/__tests__/check-pre-install-import-graph.test.ts
+++ b/ui/scripts/__tests__/check-pre-install-import-graph.test.ts
@@ -3,6 +3,7 @@ import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
+import { parse as parseYaml } from 'yaml';
// Plain-JS CI helper. Its types are INFERRED from the .mjs source by
// `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error` here —
@@ -19,6 +20,20 @@ import {
import { REQUIRED_CONTEXTS } from '../dependabot-merge-gate.mjs';
import { selfTestCases, stripAnsi } from './helpers/child-verdict';
+import {
+ globMatches,
+ jobBlock,
+ repoPath,
+ rootCi,
+ rootCiPath,
+ rootWorkflow,
+ rootWorkflowFiles,
+ triggerBlock,
+ uiFilterGlobs,
+ uiRun,
+ UI_JOBS,
+ withoutComments,
+} from './helpers/root-ci';
/**
* objectui#6148 — the gate for the property that lets a gate run pre-install.
@@ -45,8 +60,8 @@ import { selfTestCases, stripAnsi } from './helpers/child-verdict';
* 3. **the floor over the real tree** — every step objectui#6148 measured
* that still runs is still derived, by workflow and job rather than by count;
* 4. **the wiring** — a gate nobody runs is indistinguishable from a gate that
- * passes, so the workflow, the alias and the Dependabot classification are
- * asserted here rather than trusted by reading;
+ * passes, so the root `Lint UI` step, the alias and the Dependabot
+ * classification are asserted here rather than trusted by reading;
* 5. **the header states its population and never counts it** — objectui#8606.
* The header sentence describing this run carried a literal population of
* workflow files, derived from the same directory the gate derives from,
@@ -60,22 +75,18 @@ import { selfTestCases, stripAnsi } from './helpers/child-verdict';
* `lint-workflow.test.ts` records at length.
*/
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
-const workflowDir = path.join(repoRoot, '.github/workflows');
/** Where the gate reads `.github/workflows/`: the repository root, which GitHub runs. */
const rootWorkflowRoot = workflowRoot(repoRoot);
const SCRIPT = 'scripts/check-pre-install-import-graph.mjs';
-const WORKFLOW = 'repo-gates.yml';
+const ALIAS = 'check:pre-install-import-graph';
+/**
+ * The gate's home, as failure messages name it: the root `lint-ui` job, which
+ * runs objectui's former `repo-gates.yml` steps since objectstack#25902
+ * (objectstack#25712 deleted that workflow with the rest of `ui/.github/workflows/`).
+ */
+const WORKFLOW = `ci.yml (${UI_JOBS.lint})`;
const CHECK_NAME = 'Pre-Install Import Graph Check';
-/** A workflow's YAML with whole-line comments removed — every file here discusses the shapes in prose. */
-const withoutComments = (yaml: string): string =>
- yaml
- .split('\n')
- .filter((line) => !/^\s*#/.test(line))
- .join('\n');
-
-const readWorkflow = (file: string): string => fs.readFileSync(path.join(workflowDir, file), 'utf8');
-
// ── 1. the derivation is a derivation ────────────────────────────────────────
describe('the population follows the workflows', () => {
@@ -290,25 +301,27 @@ describe('the real tree — every step objectui#6148 measured is still derived',
* drift.
*
* Since objectstack#25712 the rows are the repository root's `ci.yml`, the
- * workflow GitHub runs. Two of objectui#6148's rows run there:
- * `ci.yml : type-check` is now `type-check-ui`, and `lint.yml : lint` is now
- * `lint-ui` (roster rows 7 and 16 of objectstack#25697). The others ran in
- * workflows nothing runs since the monorepo merge:
- *
- * - `changeset-guard.yml : no-major`, `changeset-presence.yml :
- * changeset-presence` and `ci.yml : changeset-check`: roster rows 3, 4
- * and 7 (→ sub-issue 4 of objectstack#25677);
- * - the four `repo-gates.yml : repo-gates` rows (`check-control-bytes`,
- * `check-doc-component-types`, `check-doc-links`, `check-skills-paths`):
- * objectstack#25880 ports those gates into `lint-ui` ahead of its
- * install, and they rejoin this floor under that job when
- * `ui/.github/workflows/` is deleted (objectstack#25712).
+ * workflow GitHub runs. Six of objectui#6148's rows run there:
+ * `ci.yml : type-check` is now `type-check-ui`, `lint.yml : lint` is now
+ * `lint-ui` (roster rows 7 and 16 of objectstack#25697), and the four
+ * `repo-gates.yml : repo-gates` rows (`check-control-bytes`,
+ * `check-doc-component-types`, `check-doc-links`, `check-skills-paths`) run in
+ * `lint-ui` ahead of its install since objectstack#25902 ported that workflow's
+ * steps there. The others ran in workflows objectstack#25712 deleted, which
+ * GitHub never ran under the monorepo: `changeset-guard.yml : no-major`,
+ * `changeset-presence.yml : changeset-presence` and `ci.yml :
+ * changeset-check`, roster rows 3, 4 and 7 (→ sub-issue 4 of
+ * objectstack#25677).
*/
const MEASURED = [
'ci.yml : type-check-ui -> scripts/check-type-check-coverage.mjs',
'ci.yml : lint-ui -> scripts/check-lint-coverage.mjs',
'ci.yml : lint-ui -> scripts/check-entry-guard.mjs',
'ci.yml : lint-ui -> scripts/check-bash32-floor.mjs',
+ 'ci.yml : lint-ui -> scripts/check-control-bytes.mjs',
+ 'ci.yml : lint-ui -> scripts/check-doc-component-types.mjs',
+ 'ci.yml : lint-ui -> scripts/check-doc-links.mjs',
+ 'ci.yml : lint-ui -> scripts/check-skills-paths.mjs',
];
it.each(MEASURED)('still derives %s', (row) => {
@@ -325,10 +338,10 @@ describe('the real tree — every step objectui#6148 measured is still derived',
it('holds its own import graph to the floor — the gate walks itself', () => {
// A floor that exempted its own enforcer would be the first thing to rot.
- // Its home was objectui's `repo-gates.yml`, which runs nowhere since the
- // monorepo merge, so it is not in its own DERIVED population until the root
- // `Lint UI` job runs it (objectstack#25712 adds that step when it deletes
- // `ui/.github/workflows/`). Until then it is walked by name.
+ // The root `Lint UI` job runs it before its install (objectstack#25902), so
+ // it is in its own DERIVED population and the findings case below covers
+ // it. The walk by name stays as the control that reaches past the entry.
+ expect(rows, 'the gate left its own derived population').toContain(`ci.yml : ${UI_JOBS.lint} -> ${SCRIPT}`);
const read = (rel: string): string | null => {
try {
return fs.readFileSync(path.join(repoRoot, rel), 'utf8');
@@ -357,24 +370,38 @@ describe('the real tree — every step objectui#6148 measured is still derived',
// ── 4. the wiring ────────────────────────────────────────────────────────────
describe('the gate is wired, not merely present', () => {
- const yaml = withoutComments(readWorkflow(WORKFLOW));
+ /**
+ * GitHub runs only the repository root's `.github/workflows/`, so the wiring is
+ * the root `ci.yml`'s: objectstack#25902 ported objectui's `repo-gates.yml`
+ * into its `lint-ui` job, which runs the gate through its alias
+ * (objectstack#25712). Every pin below reads that file by path.
+ */
+ const ci = rootCi();
+ const steps: Array> = parseYaml(ci).jobs[UI_JOBS.lint].steps;
+ const runs = (s: Record) => typeof s.run === 'string' && (s.run as string).includes(uiRun(ALIAS));
+ const gateSteps = steps.filter(runs);
it('exists, with a package.json alias that names the same file', () => {
expect(fs.existsSync(path.join(repoRoot, SCRIPT))).toBe(true);
const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
- expect(pkg.scripts['check:pre-install-import-graph']).toContain(SCRIPT);
+ expect(pkg.scripts[ALIAS]).toBe(`node ${SCRIPT}`);
});
it('runs both legs — the self-test first, then the scan', () => {
- expect(yaml).toContain(`node ${SCRIPT} --self-test`);
- expect(yaml.split('\n').some((l) => l.trim() === `node ${SCRIPT}`)).toBe(true);
+ expect(gateSteps, `${WORKFLOW} must run the gate in exactly one step`).toHaveLength(1);
+ const lines = (gateSteps[0].run as string).split('\n').map((l) => l.trim());
+ const selfTestAt = lines.indexOf(`${uiRun(ALIAS)} --self-test`);
+ const scanAt = lines.indexOf(uiRun(ALIAS));
+ expect(selfTestAt).toBeGreaterThan(-1);
+ expect(scanAt, 'the scan must follow the self-test').toBeGreaterThan(selfTestAt);
});
it('runs before its job installs anything, which is the property it exists to protect', () => {
- // Since objectui#12214 the home is `repo-gates.yml`, one job that installs
- // AFTER its install-free gates; this gate must stay on the near side.
- const gateAt = yaml.indexOf(`node ${SCRIPT} --self-test`);
- const installAt = yaml.indexOf('pnpm install');
+ // Since objectui#12214 the home is one job that installs AFTER its
+ // install-free gates (`repo-gates.yml` then, the root `lint-ui` now); this
+ // gate must stay on the near side.
+ const gateAt = steps.findIndex(runs);
+ const installAt = steps.findIndex((s) => typeof s.run === 'string' && /\bpnpm install\b/.test(s.run as string));
expect(gateAt).toBeGreaterThan(-1);
expect(installAt === -1 || gateAt < installAt, 'the gate step moved below the install step').toBe(true);
});
@@ -382,33 +409,49 @@ describe('the gate is wired, not merely present', () => {
it('carries NO path filter of any kind', () => {
// Deliberate: its input is the workflows themselves, and a workflow edit is
// the change most likely to break it. A filter would also make the check
- // unrequirable (objectui#3523).
- expect(yaml).not.toMatch(/paths-ignore:/);
- expect(yaml).not.toMatch(/^\s+paths:/m);
+ // unrequirable (objectui#3523). The root trigger filters no path; the job
+ // runs on the `filter` job's `ui` globs, which must match the workflow
+ // whose steps are the population today and the gate itself.
+ const on = withoutComments(triggerBlock(ci));
+ expect(on).not.toMatch(/paths-ignore:/);
+ expect(on).not.toMatch(/^\s+paths:/m);
+ const globs = uiFilterGlobs(ci);
+ const missed = ['.github/workflows/ci.yml', repoPath(SCRIPT)]
+ .filter((file) => !globs.some((glob) => globMatches(glob, file)));
+ expect(missed, `a change to these would skip ${WORKFLOW}`).toEqual([]);
});
it('subscribes merge_group, so a queue build is not stalled by a silent context', () => {
- expect(yaml).toMatch(/^\s{2}merge_group:/m);
+ expect(triggerBlock(ci)).toMatch(/^\s{2}merge_group:/m);
});
- it('is the only workflow that runs it — one gate, one home', () => {
- const runners = fs
- .readdirSync(workflowDir)
- .filter((f) => f.endsWith('.yml'))
- .filter((f) => withoutComments(readWorkflow(f)).includes(SCRIPT));
- expect(runners).toEqual([WORKFLOW]);
+ it('is the only job and workflow that runs it — one gate, one home', () => {
+ const jobs = Object.entries(parseYaml(ci).jobs as Record> }>)
+ .filter(([, job]) => (job.steps ?? []).some(runs))
+ .map(([key]) => key);
+ expect(jobs).toEqual([UI_JOBS.lint]);
+ const others = rootWorkflowFiles()
+ .filter((f) => f !== path.basename(rootCiPath))
+ .filter((f) => {
+ const text = withoutComments(rootWorkflow(f));
+ return text.includes(ALIAS) || text.includes(SCRIPT);
+ });
+ expect(others).toEqual([]);
});
it('is classified by the Dependabot merge gate as a blocking check', () => {
// objectui#6135: an UNCLASSIFIED blocking check is one a Dependabot merge
// would be let past. `dependabot-merge-gate.test.ts` asserts the partition
// itself; this asserts the direction that matters for this check.
- // Since objectui#12214 it reports as a step of the one `Repo Gates` context.
+ // Since objectui#12214 it reports as a step of the one `Repo Gates` context
+ // in objectui's register. In this repository the step reports under
+ // `Lint UI`, and that register is roster row 9's (→ sub-issue 4).
expect(REQUIRED_CONTEXTS).toContain('Repo Gates');
});
it('names the same check in the workflow as the gate requires', () => {
- expect(yaml).toContain(`name: ${CHECK_NAME}`);
+ expect(gateSteps).toHaveLength(1);
+ expect(String(gateSteps[0].name)).toMatch(new RegExp(`^${CHECK_NAME} — `));
});
it('passes its own self-test', () => {
@@ -549,7 +592,7 @@ describe("the header's population is a pointer, not a copy", () => {
*/
it('states no count that adding a workflow would falsify', () => {
const surfaces = [
- [WORKFLOW, headerComments(readWorkflow(WORKFLOW))],
+ [WORKFLOW, headerComments(jobBlock(rootCi(), UI_JOBS.lint))],
["this test file's own header", ownHeaderComment()],
] as const;
@@ -575,8 +618,8 @@ describe("the header's population is a pointer, not a copy", () => {
// read out of the gate. A header that stops naming it leaves the reader
// with no way to take the reading, which is the state this card found.
const dir = derivedPopulationDir();
- const prose = headerComments(readWorkflow(WORKFLOW));
- expect(prose, `${WORKFLOW}'s header must name \`${dir}\` — the population's one live source`).toContain(dir);
+ const prose = headerComments(jobBlock(rootCi(), UI_JOBS.lint));
+ expect(prose, `${WORKFLOW}'s comments must name \`${dir}\` — the population's one live source`).toContain(dir);
// …and the pointer must point at something. A named directory that holds no
// workflows is a measurement point in form only. The gate resolves it
diff --git a/ui/scripts/__tests__/ci-cd-pipeline-doc.test.ts b/ui/scripts/__tests__/ci-cd-pipeline-doc.test.ts
deleted file mode 100644
index 003feacacf1..00000000000
--- a/ui/scripts/__tests__/ci-cd-pipeline-doc.test.ts
+++ /dev/null
@@ -1,3674 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import fs from 'node:fs';
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
-import { parse as parseYaml } from 'yaml';
-
-// Plain-JS CI helper; its types are INFERRED from the .mjs source by
-// `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error` here — re-adding one
-// is itself an error (TS2578). See objectui#3494.
-import { stripComments } from '../js-comment-mask.mjs';
-
-/**
- * objectui#3197: `content/docs/guide/ci-cd-pipeline.md` described a bundle-size
- * regime that had not existed for a long time — a 60 KB console budget against a
- * workflow that enforces 350 KB (5.8x off), a `size-check.yml` workflow that has
- * never existed in this repository, and three package-size tiers printed under an
- * **Enforced limits** heading even though the step that emits them compares
- * nothing and never exits non-zero.
- *
- * Prose cannot be trusted to stay in sync with YAML by review alone — the number
- * had already drifted once and would drift again. So the doc is pinned to the
- * workflow here: change `MAX_ENTRY_GZIP_KB` (or the advisory tiers, or the set of
- * workflows the page names) without updating the page and this test fails.
- *
- * The dangerous direction is deliberately covered twice. A doc that *understates*
- * a gate is annoying; a doc that advertises a guardrail the CI does not have is
- * worse than no doc, because people make size decisions believing something will
- * stop them. Hence the last block: if anyone ever makes the size report actually
- * enforce those tiers, this test fails and points at the page that calls them
- * advisory.
- */
-const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
-const docPath = path.join(repoRoot, 'content/docs/guide/ci-cd-pipeline.md');
-const workflowPath = path.join(repoRoot, '.github/workflows/performance-budget.yml');
-const workflowDir = path.join(repoRoot, '.github/workflows');
-
-const doc = fs.readFileSync(docPath, 'utf8');
-const workflow = fs.readFileSync(workflowPath, 'utf8');
-
-/**
- * The data rows of the markdown table that begins at `fromHeader[0]`, as trimmed
- * cell arrays — separator row skipped, stopping at the first line that is not a
- * table row.
- *
- * ⭐ There is exactly ONE of these on purpose. Three blocks on this page read a
- * markdown table (the lockfile-driver table, `ci.yml`'s job table and the workflow
- * inventory), and two of them had grown their own byte-identical copy of this loop
- * before the inventory needed a third (objectui#8726). Two copies of a reader are
- * two readers, and the next fix would have reached only one of them — the same
- * argument the alias rule below is lifted to module scope for.
- *
- * ⛔ Callers keep their own `indexOf(header)` assertion: each one has a different
- * thing to say about a table that has gone missing, and a shared message would say
- * none of them well.
- */
-function markdownTableRows(fromHeader: string): string[][] {
- const rows: string[][] = [];
- for (const line of fromHeader.split('\n').slice(1)) {
- if (!line.startsWith('|')) break;
- if (/^\|[\s|:-]+\|$/.test(line)) continue; // separator
- rows.push(line.split('|').slice(1, -1).map((c) => c.trim()));
- }
- return rows;
-}
-
-/**
- * The `Generate package size report` step body, from its `- name:` line up to the
- * next step at the same indentation. Scoping matters: the *budget* step legitimately
- * exits non-zero, and asserting over the whole file would conflate the two.
- */
-function sizeReportStep(): string {
- const start = workflow.indexOf(' - name: Generate package size report');
- expect(start, 'the size-report step must still be named "Generate package size report"').toBeGreaterThan(-1);
- const rest = workflow.slice(start + 1);
- const next = rest.indexOf('\n - name: ');
- return next === -1 ? rest : rest.slice(0, next);
-}
-
-describe('ci-cd-pipeline.md — enforced console budget', () => {
- it('quotes the same MAX_ENTRY_GZIP_KB the workflow enforces', () => {
- const fromWorkflow = workflow.match(/^\s*MAX_ENTRY_GZIP_KB=(\d+)\s*$/m)?.[1];
- expect(fromWorkflow, 'MAX_ENTRY_GZIP_KB must still be assigned a literal in the workflow').toBeDefined();
-
- // The doc row that names the constant — the number lives next to it so the
- // two can never be read apart.
- const docRow = doc.split('\n').find((line) => line.includes('MAX_ENTRY_GZIP_KB') && line.startsWith('|'));
- expect(docRow, 'the doc must have a table row naming MAX_ENTRY_GZIP_KB').toBeDefined();
-
- const fromDoc = docRow!.match(/\*\*(\d+) KB\*\*/)?.[1];
- expect(
- fromDoc,
- `ci-cd-pipeline.md must state the console entry budget as "**${fromWorkflow} KB**"`,
- ).toBe(fromWorkflow);
- });
-
- it('does not leave the superseded 60 KB figure anywhere on the page', () => {
- expect(doc).not.toMatch(/\b60 ?KB\b/);
- });
-});
-
-describe('ci-cd-pipeline.md — advisory package size tiers', () => {
- // `- ✅ Core packages should be < 50KB gzipped`, ×3.
- const tiersFromWorkflow = [...workflow.matchAll(/should be < (\d+)KB gzipped/g)].map((m) => m[1]);
- // `| Core packages | < 50 KB | **No** — advisory only |`
- const advisoryRows = doc.split('\n').filter((line) => /^\|.*\|\s*<\s*\d+ KB\s*\|/.test(line));
-
- it('lists the same three tiers the workflow echoes into the report', () => {
- expect(tiersFromWorkflow).toEqual(['50', '100', '150']);
- expect(advisoryRows.map((row) => row.match(/<\s*(\d+) KB/)![1])).toEqual(tiersFromWorkflow);
- });
-
- it('marks every tier as NOT enforced', () => {
- expect(advisoryRows).toHaveLength(3);
- for (const row of advisoryRows) {
- expect(row, `advisory tier row must say it is not enforced: ${row}`).toMatch(/\*\*No\*\*/);
- }
- expect(doc).toMatch(/### Package size report — advisory, not a gate/);
- });
-
- it('is telling the truth: the size-report step compares nothing and cannot fail', () => {
- // The inverse pin. If someone turns the tiers into a real gate, this fails —
- // which is the moment the "advisory only" wording above must be rewritten.
- const step = sizeReportStep();
- expect(step).not.toMatch(/\bexit 1\b/);
- expect(step).not.toMatch(/\b(50|100|150)\b\s*\)?\s*(?:\]\]|\))?\s*(?:&&|\|\||;|then)/);
- expect(step).not.toMatch(/-(gt|lt|ge|le)\s/);
- });
-});
-
-/**
- * objectui#3212: the forward direction above (every workflow the page names must
- * exist) was pinned by #3197; the reverse was deliberately left out because it
- * would have gone red immediately — `lint.yml`, `cross-repo-issue-closer.yml`
- * and later `changeset-guard.yml` had no section at all. `lint.yml` is a real PR
- * gate, and a contributor reading this page had no way to learn it existed.
- *
- * Only the reverse direction actually stops the drift. Without it, fixing the
- * page fixes one snapshot and guarantees the next workflow repeats the omission
- * silently — `changeset-guard.yml` appearing between #3212 being filed and being
- * fixed is the proof.
- *
- * A *heading* is required, not a passing mention: a filename buried in a table
- * row or an ASCII box is how the page got here. The heading is what makes the
- * workflow findable and forces someone to write down what it does.
- */
-
-/**
- * `filename -> why this workflow must not be documented`. Deliberately empty.
- *
- * A workflow that runs in this repository is a workflow contributors can be
- * blocked by, so "not worth a section" is a claim that has to be made
- * explicitly and reviewed — never by quietly skipping the page. The test below
- * also rejects entries that name a workflow which no longer exists, so the
- * escape hatch cannot rot into a permanent hole.
- */
-const DOCUMENTATION_EXEMPT = new Map();
-
-describe('ci-cd-pipeline.md — workflow inventory', () => {
- const workflowFiles = new Set(fs.readdirSync(workflowDir).filter((f) => f.endsWith('.yml')));
-
- /** Workflow filenames named in a markdown heading, e.g. `## Lint (\`lint.yml\`)`. */
- const documented = new Set(
- doc
- .split('\n')
- .filter((line) => /^#{1,6}\s/.test(line))
- .flatMap((line) => [...line.matchAll(/([a-z0-9][a-z0-9-]*\.yml)\b/g)].map((m) => m[1])),
- );
-
- it('gives every workflow in .github/workflows/ its own section', () => {
- const undocumented = [...workflowFiles].filter(
- (f) => !documented.has(f) && !DOCUMENTATION_EXEMPT.has(f),
- );
-
- expect(
- undocumented,
- `These workflows exist in .github/workflows/ but no heading in ` +
- `content/docs/guide/ci-cd-pipeline.md names them:\n` +
- undocumented.map((f) => ` - ${f}`).join('\n') +
- `\n\nAdd a section to that page — a heading that contains the file name ` +
- `(e.g. "### Hook Self-Tests (\`hook-selftests.yml\`)"), what triggers it, and whether it can ` +
- `block a merge — and a row in the "Workflow Inventory" table. A workflow nobody ` +
- `documented is a check contributors get blocked by without knowing it exists ` +
- `(objectui#3212: \`lint.yml\` gated PRs for months while this page never mentioned it).` +
- `\n\nIf a workflow genuinely must not be documented, add it to DOCUMENTATION_EXEMPT in ` +
- `this file with the reason — the exemption is reviewable, skipping the page is not.`,
- ).toEqual([]);
- });
-
- /**
- * ── The inventory table, and WHAT A ROW MUST ASSERT (objectui#8726) ─────────
- *
- * The set above is built from `/^#{1,6}\s/` lines — markdown headings only — so
- * until now the "Workflow Inventory" table was read by nothing at all. A workflow
- * satisfied `gives every workflow … its own section` purely by having a heading,
- * while its row could be absent, duplicated, or say the opposite of the truth.
- * Two mutations on the merged tree proved it, both green where they should have
- * been red: deleting `lockfile-integrity.yml`'s row while keeping its `##`
- * section, and rewriting `live-e2e.yml`'s trailing cell back to the stale
- * `continue-on-error` wording objectui#8692 had just removed.
- *
- * ⭐ The second one is why "does the row exist?" is not the whole answer. The page
- * asserts the `continue-on-error` claim about that lane in its structural-claims
- * bullet, and objectui#8084 took the property off the job — so the page could carry
- * a *contradiction with itself*, a corrected bullet beside an uncorrected row, and
- * stay green. Existence alone does not see that.
- *
- * ⭐ So a row must: (1) exist for every non-exempt workflow and name no other,
- * (2) appear exactly once, and (3) make no claim about a YAML property that the
- * workflow it names does not declare. Nothing more.
- *
- * ⛔ Deliberately NOT pinned: whether the free prose of `Runs on` and `Blocks a PR?`
- * is *correct*. Those cells carry real nuance ("every job but the two coverage-lane
- * jobs", "ESLint **errors** only") that no derivation reproduces, and the honest
- * source for a blocking answer is the repository's required-context set, which lives
- * in GitHub's settings and not in this tree. Deriving "Blocks a PR?" from a
- * `merge_group` trigger would be a *false* derivation in the direction that matters:
- * `hook-selftests.yml` declares none and the page rightly answers **Yes**. Pinning
- * more than the page can honestly promise is how a pin becomes the next card, so what
- * (3) checks is the narrow, mechanical thing — a named YAML key the row itself
- * invokes — the same discipline STRUCTURAL_BLOCKS below applies to the bullet.
- *
- * ⭐ The failure message above tells a contributor to add a heading **and a row**.
- * That last clause was advice no assertion enforced. It is enforced here now, so the
- * message and the assertions agree; ⛔ if these are ever retired, the clause goes too.
- */
- const INVENTORY_TABLE_HEADER = '| Workflow file | Appears as | Runs on | Blocks a PR? |';
-
- /** Inventory rows: the `.yml` the first cell names, and the whole row as text. */
- function inventoryRows(): { file: string; text: string }[] {
- const at = doc.indexOf(INVENTORY_TABLE_HEADER);
- expect(
- at,
- 'the "Workflow Inventory" table of content/docs/guide/ci-cd-pipeline.md no longer has the ' +
- `header \`${INVENTORY_TABLE_HEADER}\`. Everything below reads that table through this ` +
- 'header, so a renamed or reordered column turns the whole block vacuously green — the ' +
- 'exact failure objectui#8726 was filed about. Restore the header, or teach it the new one.',
- ).toBeGreaterThan(-1);
-
- return markdownTableRows(doc.slice(at)).map((cells) => ({
- file: cells[0].match(/([a-z0-9][a-z0-9-]*\.yml)\b/)?.[1] ?? '',
- text: cells.join(' | '),
- }));
- }
-
- it('the inventory table parse is live — a zero-row read is a broken reader, not an empty table', () => {
- // The control every count below rests on. A header that stopped matching, a
- // table converted to a list, or a separator regex that swallowed the rows would
- // make "no row is missing" true by comparing nothing at all (objectui#6436).
- const rows = inventoryRows();
- expect(
- rows.length,
- 'the "Workflow Inventory" table parsed to implausibly few rows. This repository has ' +
- 'dozens of workflows and each is supposed to have one; a handful means the reader ' +
- 'broke, not that the page shrank.',
- ).toBeGreaterThan(5);
-
- expect(
- rows.filter((r) => r.file === '').map((r) => r.text),
- 'these inventory rows have no `NAME.yml` in their first cell, so nothing below can ' +
- 'match them to a workflow. The first column is the join key — write the file name ' +
- 'there, in backticks.',
- ).toEqual([]);
- });
-
- it('gives every workflow in .github/workflows/ a row in the inventory table — in both directions', () => {
- const listed = inventoryRows().map((r) => r.file);
-
- const missing = [...workflowFiles].filter(
- (f) => !listed.includes(f) && !DOCUMENTATION_EXEMPT.has(f),
- );
- expect(
- missing,
- `These workflows exist in .github/workflows/ and have a section on ` +
- `content/docs/guide/ci-cd-pipeline.md, but no row in its "Workflow Inventory" table:\n` +
- missing.map((f) => ` - ${f}`).join('\n') +
- `\n\nAdd the row — file name, the name it appears under in the checks list, what it runs ` +
- `on, and whether it can block a PR. ⭐ The table is the only place a contributor can see ` +
- `the whole set at once; a workflow present in the prose but missing from it reads as one ` +
- `that does not exist (objectui#8726: a merge conflict resolution that dropped a row would ` +
- `have been invisible to this suite).` +
- `\n\nIf the workflow genuinely must not be documented, add it to DOCUMENTATION_EXEMPT in ` +
- `this file with the reason — it then needs neither a section nor a row.`,
- ).toEqual([]);
-
- // The phantom direction overlaps the whole-page scan in `never names a workflow
- // file that does not exist`; it is kept row-scoped because the message a reader
- // needs here names the row to delete, not a filename somewhere on the page.
- const phantom = listed.filter((f) => f !== '' && !workflowFiles.has(f));
- expect(
- phantom,
- `the "Workflow Inventory" table has rows for workflows that are NOT in ` +
- `.github/workflows/:\n` +
- phantom.map((f) => ` - ${f}`).join('\n') +
- `\n\nDelete the row. A table that advertises a workflow the repository does not run is ` +
- `worse than no table — objectui#3451 measured that exact rot on this page's job table.`,
- ).toEqual([]);
- });
-
- it('names each workflow in exactly one inventory row', () => {
- const listed = inventoryRows().map((r) => r.file).filter(Boolean);
- const duplicated = [...new Set(listed.filter((f, i) => listed.indexOf(f) !== i))];
-
- expect(
- duplicated,
- `these workflows have more than one row in the "Workflow Inventory" table:\n` +
- duplicated.map((f) => ` - ${f}`).join('\n') +
- `\n\nTwo rows for one workflow are two answers to "can it block a PR?", and the set ` +
- `comparison above cannot tell them apart — it is satisfied by either. A duplicate is how ` +
- `a conflict resolution that kept both sides survives review (objectui#8726).`,
- ).toEqual([]);
- });
-
- /**
- * (3), the claim half. A row that INVOKES a YAML key is making an assertion about
- * the workflow it names, and that assertion is readable out of the YAML. A row that
- * invokes none is asked for nothing — these two cases are dormant by design, and the
- * control each carries is what keeps a dormant pin from being an unfalsifiable one.
- */
- it('makes no `continue-on-error` claim the workflow YAML does not carry', () => {
- const jobLevel = (file: string) =>
- withoutComments(readWorkflow(file))
- .split('\n')
- .filter((line) => /^ {4}continue-on-error\s*:/.test(line));
-
- // Control. Zero job-level declarations is the whole repository's state today
- // (objectui#8084 removed the last one), so "no workflow carries it" has to be a
- // reading rather than a broken grep: the same scan one indent deeper must still
- // find the step-level flags on the cache saves, which are a different thing and stay.
- const stepLevelAnywhere = [...workflowFiles].filter((f) =>
- /^ {5,}continue-on-error\s*:/m.test(withoutComments(readWorkflow(f))),
- );
- expect(
- stepLevelAnywhere,
- 'the indent-scoped `continue-on-error` scan matched NO workflow at any depth, but the ' +
- 'cache-save steps declare it. The directory listing, the file reads or the regex is ' +
- 'broken — and until it is fixed, "no job declares continue-on-error" is not a ' +
- 'measurement (objectui#6436).',
- ).not.toEqual([]);
-
- const unsupported = inventoryRows()
- .filter((r) => r.file !== '' && workflowFiles.has(r.file))
- .filter((r) => r.text.includes('continue-on-error') && jobLevel(r.file).length === 0);
-
- expect(
- unsupported.map((r) => r.file),
- `these "Workflow Inventory" rows invoke \`continue-on-error\` to explain their lane, but ` +
- `the job in the workflow they name declares no such key:\n` +
- unsupported.map((r) => ` - ${r.file} — "${r.text.slice(0, 120)}…"`).join('\n') +
- `\n\n⭐ This is the drift objectui#8726 was filed for. objectui#8084 took the flag off ` +
- `\`live-e2e\` because it made the run conclusion disagree with the job, objectui#8692 ` +
- `removed the wording from this table, and the page's structural-claims bullet is pinned ` +
- `to the property below — so a row that says it again puts the page in contradiction with ` +
- `itself while every other test stays green. Say what the lane actually is (not in the ` +
- `required-check set, no \`merge_group\` trigger), or restore the key in the YAML and ` +
- `argue for it there. NOTE step-level \`continue-on-error:\` on a cache save is a ` +
- `different thing and is not what this row would be describing.`,
- ).toEqual([]);
- });
-
- it('makes no `merge_group` claim the workflow YAML contradicts', () => {
- const declaresMergeGroup = (file: string) =>
- /^\s{2}merge_group\s*:/m.test(withoutComments(readWorkflow(file)));
-
- // Control: the trigger scan must still see the workflows that do subscribe, or
- // "this one declares none" is a claim about a grep and not about the YAML.
- expect(
- [...workflowFiles].filter(declaresMergeGroup),
- 'the `merge_group` trigger scan matched NO workflow, but `ci.yml` and `lint.yml` are ' +
- 'queue-build subscribers. Nothing below is a reading until that is fixed (objectui#6436).',
- ).not.toEqual([]);
-
- const contradicting = inventoryRows()
- .filter((r) => r.file !== '' && workflowFiles.has(r.file))
- .filter((r) => /no\s+`merge_group`/.test(r.text) && declaresMergeGroup(r.file));
-
- expect(
- contradicting.map((r) => r.file),
- `these "Workflow Inventory" rows say their workflow declares no \`merge_group\` trigger, ` +
- `but it does:\n` +
- contradicting.map((r) => ` - ${r.file}`).join('\n') +
- `\n\nA workflow that subscribes to \`merge_group\` produces a context on queue builds and ` +
- `so CAN be required — which is the opposite of what the row tells a reader deciding ` +
- `whether to wait for it. Update the cell, or drop the trigger.`,
- ).toEqual([]);
- });
-
- it('keeps the documentation exemption list honest', () => {
- for (const [name, reason] of DOCUMENTATION_EXEMPT) {
- expect(workflowFiles, `DOCUMENTATION_EXEMPT names ${name}, which no longer exists — drop it`).toContain(name);
- expect(reason.length, `DOCUMENTATION_EXEMPT[${name}] must carry a real justification`).toBeGreaterThan(20);
- }
- });
-
- it('never names a workflow file that does not exist', () => {
- // Fenced blocks are excluded: they hold YAML samples for workflows that do
- // not exist yet ("Adding a New Workflow") and, until #3212, an ASCII overview
- // box that wrapped filenames across lines (`performance-` / `budget.yml`),
- // which no filename scan can read.
- const prose = doc.replace(/```[\s\S]*?```/g, '');
- // Skip path-qualified mentions (`.github/labeler.yml` is the labeler *config*,
- // not the workflow of the same name).
- const named = new Set([...prose.matchAll(/(? m[1]));
-
- expect(named.size).toBeGreaterThan(5);
- for (const name of named) {
- expect(workflowFiles, `ci-cd-pipeline.md documents ${name}, which is not in .github/workflows/`).toContain(name);
- }
- });
-
- /**
- * objectui#3724: this page was not the only workflow inventory. `.github/WORKFLOWS.md`
- * held a second one — hand-maintained, linked from nowhere, and pinned by nothing —
- * which had drifted to documenting 5 workflows that did not exist (including a
- * changeset gate skippable with a `skip-changeset` label; neither the workflow nor the
- * label was ever real) while omitting 9 that did, `lint.yml` among them. That is #3212
- * verbatim, on a page no test could see.
- *
- * A duplicate inventory is a drift generator by construction: the pins above make *this*
- * page track `.github/workflows/` in both directions, and a second copy inherits none of
- * that while reading just as authoritative. So the resolution was deletion, not a second
- * ratchet, and this asserts the deletion holds.
- *
- * Scope, stated so it is not mistaken for more: it pins the one path that existed. A new
- * inventory under a different name evades it — the durable protection is that adding a
- * workflow already forces an edit *here*, so a second page earns nothing.
- */
- it('keeps this page as the only workflow inventory', () => {
- expect(
- fs.existsSync(path.join(repoRoot, '.github/WORKFLOWS.md')),
- '`.github/WORKFLOWS.md` is back. It was deleted by objectui#3724 because an unpinned ' +
- 'second copy of the workflow inventory drifts to 5 phantom workflows and 9 omissions ' +
- 'while nothing checks it. Document workflows in content/docs/guide/ci-cd-pipeline.md, ' +
- 'which the tests in this file hold to `.github/workflows/` in both directions.',
- ).toBe(false);
- });
-
- it('does not resurrect the never-existent size-check.yml', () => {
- // Checked over the whole file, fences included — the stale claim lived in the
- // ASCII overview box as well as in its own section. (The page may still say
- // the words "size-check" while denying that such a workflow exists; what it
- // must never do again is name the file.)
- expect(doc).not.toMatch(/size-check\.yml/);
- expect(workflowFiles).not.toContain('size-check.yml');
- });
-
- /**
- * objectui#4912: `skip-changeset` is the other phantom that deleted page left behind, and
- * unlike `size-check.yml` it did not stay dead. The label *object* was re-minted in this
- * repository's label set — GitHub creates a label the first time one is applied by name, so
- * one API call that applies it is enough — and by 2026-08-25 it sat on seven pull requests,
- * carrying the default grey `ededed` and the empty description that tell an auto-minted
- * label apart from a curated one. It is now actively read as a mechanism: a triage comment
- * on objectui#6243 instructed a PR to carry it, the developer refused on the grounds
- * recorded above, and the refusal was upheld on PR #6260. That is the exact harm #4912
- * predicted, arriving after the card was filed.
- *
- * ⭐ The object was deleted under #4912 on 2026-09-09, once its stated precondition — that
- * no open PR carried it — had been measured. ⛔ That does not retire these two pins, and
- * they must not be read as obsolete: one API call that applies the name mints the label
- * again, and the instruction to apply it still reaches agents from the `objectstack`
- * sibling, so its absence is a moment and not a state this repository can hold.
- *
- * ⛔ What these two cases can and cannot see, said plainly because the boundary is the whole
- * design of this file: **a label lives in GitHub's data, not in the tree, so nothing here
- * can assert the label object is gone.** Deleting it is an administrative act, and a test
- * that reached for the labels API would put a network call and a credential in a suite that
- * deliberately has neither. The pins below are therefore not restatements of "the label does
- * nothing" — that needs no pin. They hold the two things that *are* ours and that a future
- * commit could change without anyone noticing.
- */
- it('never wires the phantom `skip-changeset` label into a workflow or a gate', () => {
- // Option B of #4912 — give the changeset gates a labelled skip path — was declined by
- // #3724 and again by the #4912 ruling: the presence gate has no bypass by design, and its
- // exemption is an empty-frontmatter changeset, which lives in the repo where the next
- // reader finds it rather than in a label that vanishes from history. So a read of this
- // name appearing under `.github/` or `scripts/` is that declined option landing without a
- // decision. The name IS wired in the `objectstack` sibling (lint.yml, pr-automation.yml,
- // check-empty-changeset.mjs), which is how it reaches agents who then look for it here —
- // copying that wiring across is precisely what this catches.
- const selfPath = path.resolve(fileURLToPath(import.meta.url));
- const offenders: string[] = [];
-
- function scan(dir: string): void {
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
- const full = path.join(dir, entry.name);
- if (entry.isDirectory()) {
- if (entry.name === 'node_modules' || entry.name === '.turbo') continue;
- scan(full);
- } else if (entry.isFile() && path.resolve(full) !== selfPath) {
- // This file is the one legitimate mention: it records the history above.
- if (fs.readFileSync(full, 'utf8').includes('skip-changeset')) {
- offenders.push(path.relative(repoRoot, full));
- }
- }
- }
- }
-
- for (const root of ['.github', 'scripts']) scan(path.join(repoRoot, root));
-
- expect(
- offenders.sort(),
- `these files under .github/ or scripts/ mention \`skip-changeset\`:\n` +
- offenders.map((f) => ` - ${f}`).join('\n') +
- `\n\nNo gate in this repository reads that label and none is supposed to. If this is a ` +
- `workflow or script that now honours it, that is objectui#4912 option B — a labelled ` +
- `bypass on a changeset gate that deliberately has none — and it was declined twice ` +
- `(#3724, and the #4912 ruling). Land the decision to reverse those before the code. ` +
- `To declare that a PR publishes nothing, add a changeset with empty frontmatter; that ` +
- `is the mechanism, and it is the one content/docs/guide/ci-cd-pipeline.md documents.`,
- ).toEqual([]);
- });
-
- it('keeps the page denying `skip-changeset` rather than describing it', () => {
- // `ci-cd-pipeline.md` is where a contributor looks up "how do I declare this PR publishes
- // nothing", so the page is the surface that decides whether the next reader believes the
- // label. Unlike the `size-check.yml` pin, absence is the wrong assertion here: the page
- // has to keep NAMING the label in order to deny it, or the phantom is simply undocumented
- // again. So the denial itself is what gets pinned.
- //
- // Blockquote markers are stripped before matching — the whole passage is a `>` block, and
- // its sentences wrap across lines, so a raw substring search would silently never match
- // and this pin would be vacuously green.
- const prose = doc.replace(/^[ \t]*>[ \t]?/gm, '').replace(/\s+/g, ' ');
-
- expect(
- prose,
- 'content/docs/guide/ci-cd-pipeline.md must keep stating that nothing reads the ' +
- '`skip-changeset` label. The label object was auto-minted in this repository once by ' +
- 'being applied, and deleting it (objectui#4912) does not stop one API call from ' +
- 'minting it again while agents are still told to use it, so a page that stops ' +
- 'denying it leaves the label as the most authoritative-looking answer in reach.',
- ).toContain('no gate in this repository reads it');
-
- expect(
- prose,
- 'the page must keep telling the reader what to do INSTEAD of the label — a denial with ' +
- 'no alternative sends them back to the label. The real exemption is a changeset with ' +
- 'empty frontmatter.',
- ).toContain('declare an empty changeset instead');
-
- // The 2026-08-08 labels-API reading this note used to carry ("the label still does not
- // exist") had expired by the time #4912 was worked: the object had been re-minted. A
- // point-in-time API reading is not a fact this repository can keep true, and restating one
- // is how the page came to assert something false about the very phantom it documents.
- expect(
- prose,
- 'do not put a point-in-time labels-API reading back on this page. Nothing in the tree ' +
- 'can keep it true, and the last one was false within weeks (objectui#4912).',
- ).not.toContain('checked against the labels API');
- });
-});
-
-/**
- * objectui#3724: the `pnpm-lock.yaml` merge driver is repository configuration
- * (`.gitattributes`) that only works where something defines the driver, so "which
- * workflows define it" is a claim about three files at once — and it was wrong in both
- * copies that made it. This page named `changeset-release.yml` and
- * `dependabot-auto-merge.yml`; the deleted `.github/WORKFLOWS.md` named
- * `changeset-release.yml` and `changelog.yml`. Each omitted a different third, and both
- * read as complete.
- *
- * The content survived the deletion — the `.gitattributes` half and the add-it-to-a-new-
- * workflow half existed nowhere else — so it moved onto this page. Moving an already-drifted
- * hand-maintained list into unpinned prose would just relocate the drift generator, which is
- * the whole reason #3724 chose deletion over a second copy. Hence this pin: both directions,
- * so a workflow that gains the step without a row is as red as a row whose workflow lost it.
- *
- * ─────────────────────────────────────────────────────────────────────────────────────────
- * objectui#6436 (ruled 2026-08-27) removed the LAST workflow that configured the driver.
- * `changeset-release.yml` performs no local merge, so its config could never fire; the
- * repository half (`.gitattributes`) and the contributor half (`CONTRIBUTING.md`) were
- * measured live and were kept. The mechanism is now contributor-facing with zero CI
- * consumers.
- *
- * ⭐ THE ANTI-VACUITY WEIGHT MOVED — IT DID NOT VANISH. This block used to open with
- * `expect(configuring.length).toBeGreaterThan(0)`, and that single line was doing two
- * different jobs:
- *
- * 1. proving the grep had matched something, so "no workflow is missing a row" was a
- * reading rather than an artifact of a scan that read nothing; and
- * 2. proving the mechanism still had a consumer at all — a zero-match grep once let both
- * directions of this pin go vacuously green, which is why the guard was added.
- *
- * Zero is now the CORRECT answer to that count, so the guard cannot stay as `> 0`. ⛔ But
- * flipping it to `=== 0` and stopping there would delete both jobs and re-install the exact
- * defect this block exists to catch. Each job therefore has a new and explicit owner:
- *
- * 1. → `the workflow scan is live` below. A zero-hit taken with no control is not a
- * reading. The scan now takes a control term that MUST hit the same population —
- * `git config`, which `changelog.yml` genuinely runs. If the directory, the read or
- * the regex ever breaks, the control collapses to zero as well and that test fails,
- * instead of a broken scan silently agreeing with the expected zero.
- * 2. → `keeps the .gitattributes half of the mechanism true` and `keeps CONTRIBUTING.md's
- * contributor path — the mechanism's only remaining consumer` below. That is where the
- * real weight landed: those two files ARE the mechanism now. If either goes, the driver
- * genuinely has no consumers left and this section should be deleted with it — which is
- * exactly what the old `> 0` failure message demanded, re-pointed at the consumer that
- * is actually live.
- *
- * The both-directions pin itself stays, and the PHANTOM direction is now the load-bearing
- * half of it: with zero configuring workflows, `phantom` is precisely "workflows this page
- * still names", so a row that comes back reddens on its own. The `missing` direction is the
- * tripwire for a CI consumer returning.
- */
-describe('ci-cd-pipeline.md — lockfile merge driver', () => {
- /** Workflows that actually configure the driver, by grepping for the git config key. */
- function workflowsConfiguringDriver(): string[] {
- return fs
- .readdirSync(workflowDir)
- .filter((f) => f.endsWith('.yml'))
- .filter((f) => /merge\.pnpm-merge/.test(fs.readFileSync(path.join(workflowDir, f), 'utf8')))
- .sort();
- }
-
- /**
- * The control population: workflows containing a `git config` of any kind. Deliberately a
- * NEAR MISS of the driver key — both are `git config` lines in `.github/workflows/`, read
- * out of the same directory by the same reader — so a scan that can see one can see the
- * other. `changelog.yml` really does run `git config --local user.email`, so zero here
- * means the scan is broken, not that the repository changed.
- */
- function workflowsMatchingControl(): string[] {
- return fs
- .readdirSync(workflowDir)
- .filter((f) => f.endsWith('.yml'))
- .filter((f) => /git config/.test(fs.readFileSync(path.join(workflowDir, f), 'utf8')))
- .sort();
- }
-
- const DRIVER_TABLE_HEADER = '| Workflow | Why it needs the driver |';
-
- /**
- * The workflows named in that section's table. Scoped to the table rows on purpose: the
- * surrounding prose names all three again while recounting the drift, and a set built from
- * the whole section would stay green after the table itself was gutted.
- */
- function workflowsNamedInDoc(): string[] {
- const start = doc.indexOf('## Lockfile Merge Driver');
- expect(start, 'the page must keep a "## Lockfile Merge Driver" section').toBeGreaterThan(-1);
- const rest = doc.slice(start + 2);
- const next = rest.search(/^## /m);
- const section = next === -1 ? rest : rest.slice(0, next);
-
- const at = section.indexOf(DRIVER_TABLE_HEADER);
- expect(at, `that section must keep the table header \`${DRIVER_TABLE_HEADER}\``).toBeGreaterThan(-1);
-
- const named = new Set();
- for (const cells of markdownTableRows(section.slice(at))) {
- for (const m of cells.join(' | ').matchAll(/([a-z0-9][a-z0-9-]*\.yml)\b/g)) named.add(m[1]);
- }
- return [...named].sort();
- }
-
- it('the workflow scan is live — zero configuring workflows is a reading, not a broken grep', () => {
- // This test is the replacement for the retired `configuring.length > 0` guard, in its
- // first job only: proving the scan works. It says nothing about whether the driver has a
- // consumer — that question moved to the two `keeps …` tests below.
- const files = fs.readdirSync(workflowDir).filter((f) => f.endsWith('.yml'));
- expect(
- files.length,
- `no .yml files were read out of ${workflowDir} at all, so every count this block takes ` +
- 'is zero for a reason that has nothing to do with the merge driver',
- ).toBeGreaterThan(0);
-
- expect(
- workflowsMatchingControl(),
- 'the control term `git config` matched NO workflow, but `changelog.yml` runs ' +
- '`git config --local user.email`. The directory listing, the file reads or the regex ' +
- 'is broken — and until it is fixed, "zero workflows configure the merge driver" is ' +
- 'not a measurement. A zero-hit taken with no control is not a reading (objectui#6436).',
- ).not.toEqual([]);
- });
-
- it('lists exactly the workflows that configure merge.pnpm-merge — in both directions', () => {
- const configuring = workflowsConfiguringDriver();
- const named = workflowsNamedInDoc();
- const missing = configuring.filter((f) => !named.includes(f));
- const phantom = named.filter((f) => !configuring.includes(f));
-
- expect(
- missing,
- `these workflows configure the pnpm-lock.yaml merge driver but have no row in the ` +
- `"Lockfile Merge Driver" table of content/docs/guide/ci-cd-pipeline.md:\n` +
- missing.map((f) => ` - ${f}`).join('\n') +
- `\n\nSince objectui#6436 the expected count is ZERO, so this is a CI consumer coming ` +
- `back. Either it genuinely merges on the runner — in which case add its row, saying ` +
- `why it merges — or it is configuring a driver it can never use, which is the dead ` +
- `half that card removed. NOTE the scan greps workflow text, so merely NAMING the ` +
- `config key in a comment trips this; that is the safe direction, but spell the key ` +
- `as the \`merge=pnpm-merge\` attribute in prose to avoid it.`,
- ).toEqual([]);
-
- expect(
- phantom,
- `the "Lockfile Merge Driver" table names workflows that do NOT configure ` +
- `\`merge.pnpm-merge\`:\n` +
- phantom.map((f) => ` - ${f}`).join('\n') +
- `\n\n⭐ This direction is load-bearing now. With zero configuring workflows, it says ` +
- `exactly "the page names no workflow", so a row that comes back — stale, or copied ` +
- `from the pre-objectui#6436 page — fails here on its own rather than riding along ` +
- `with a count that happened to be non-zero. Either the workflow really configures ` +
- `the driver, or the row goes.`,
- ).toEqual([]);
- });
-
- it('keeps the .gitattributes half of the mechanism true', () => {
- // ⭐ Load-bearing since objectui#6436: with no workflow configuring the driver, the
- // attribute is the mechanism's only presence IN this repository. It used to be checked
- // because "the workflows would be configuring it for nothing"; there are no workflows to
- // configure it now, so this assertion is no longer a corollary of anything else.
- const attributes = fs.readFileSync(path.join(repoRoot, '.gitattributes'), 'utf8');
- expect(
- attributes,
- '.gitattributes no longer routes pnpm-lock.yaml through the `pnpm-merge` driver. Since ' +
- 'objectui#6436 removed the last workflow that configured it, this line is the only ' +
- 'thing in the repository that asks for the driver at all — without it the mechanism ' +
- 'is gone, contributors who configured the driver silently stop getting it, and the ' +
- '"Lockfile Merge Driver" section of content/docs/guide/ci-cd-pipeline.md describes ' +
- 'nothing. Removing it is a decision, not a cleanup: measured, a conflicting merge ' +
- 'without this attribute leaves conflict markers INSIDE pnpm-lock.yaml.',
- ).toMatch(/^pnpm-lock\.yaml\s+merge=pnpm-merge\s*$/m);
- });
-
- it("keeps CONTRIBUTING.md's contributor path — the mechanism's only remaining consumer", () => {
- // ⭐ This is where the retired `configuring.length > 0` guard's SECOND job landed. That
- // guard asked "does anything still consume this driver?" and answered with the workflow
- // count. The workflow count is zero by ruling now, so the question is answered here, at
- // the consumer that was measured live: CONTRIBUTING.md defines the driver locally and
- // then runs a merge that gives it an occasion to fire.
- const contributing = fs.readFileSync(path.join(repoRoot, 'CONTRIBUTING.md'), 'utf8');
-
- expect(
- contributing,
- 'CONTRIBUTING.md no longer tells contributors to define `merge.pnpm-merge.driver`. An ' +
- 'attribute names a driver but does not define one, and since objectui#6436 no ' +
- 'workflow defines it either — so if this instruction is gone, `.gitattributes:5` ' +
- 'selects a driver that exists nowhere and silently falls back to a text merge. At ' +
- 'that point the mechanism has zero consumers and the "Lockfile Merge Driver" section ' +
- 'of content/docs/guide/ci-cd-pipeline.md, plus the .gitattributes line, should be ' +
- 'deleted with it — which is what the retired `configuring.length > 0` guard demanded, ' +
- 'pointed at the consumer that is actually live.',
- ).toMatch(/git config\s+merge\.pnpm-merge\.driver/);
-
- expect(
- contributing,
- "CONTRIBUTING.md still defines the driver but no longer tells contributors to merge " +
- 'upstream, so nothing in the documented workflow gives the driver an occasion to ' +
- 'fire. A configured driver with no merge is the same dead shape objectui#6436 removed ' +
- 'from changeset-release.yml — if the contributor path really no longer merges, the ' +
- 'driver instruction above it is dead too and this whole mechanism should be re-judged.',
- ).toMatch(/git merge\s+upstream\/main/);
- });
-});
-
-/**
- * ── What a job RUNS, paired against the prose that documents it ──────────────
- *
- * objectui#3653 introduced this pairing for `ci.yml`'s job table. objectui#8015
- * lifted it out of that table, because the rule it encodes was never about a
- * table: *every first-party command a job runs must be named where this page
- * documents that job, and that documentation may not credit it with one the job
- * does not run.*
- *
- * Both directions are asserted, because the documentation is a claim in both: a
- * command the job runs and the page omits is a contributor who cannot learn from
- * this page that a gate exists; a command the page names and the job does not run
- * is the objectui#3451 shape one level down — a page advertising a guardrail that
- * is not there.
- *
- * The two sources differ only in WHERE the prose lives — one table cell per job
- * for `ci.yml`, one whole `##` section for `lint.yml`'s single job — so that is
- * the only thing a caller supplies. The rule itself is written once, here.
- *
- * What counts as a command is deliberately narrower than "every step", and the
- * boundary is *derived* rather than hand-listed: a step counts when it names
- * something this repository owns — a `scripts/*.mjs` file, a script in the root
- * `package.json`, or a `turbo run` task. Environment setup drops out on its own
- * because it names none of those (`corepack enable`, `pnpm --version`, `pnpm
- * install --frozen-lockfile`, `pnpm exec playwright install`, `pnpm --filter …
- * exec vite build`, `pnpm --filter '@object-ui/cli...' build`), which keeps the
- * documentation a summary of the gates rather than a transcript of the YAML.
- *
- * The hole that leaves, stated so nobody mistakes it for coverage: a gate written
- * as an inline shell block names no first-party command and is invisible here.
- * Gates in this repository land as a root `package.json` script or a
- * `scripts/*.mjs` file — both covered — and that is the only reason the narrower
- * rule is enough.
- *
- * Steps are read from `run:` values only, never from the surrounding YAML. Both
- * workflows carry comments that name their own gates — `ci.yml`'s `type-check`
- * block mentions `pnpm type-check`, `turbo run type-check` and `pnpm
- * check:i18n-drift`, and `lint.yml`'s step comments name every script it runs
- * plus two `pnpm check:*` aliases it deliberately does NOT use — so a scan of the
- * raw block would take all of them for steps and this pin would then be
- * describing its own comments.
- *
- * A `--self-test` invocation and the real one collapse to one entry: the rule is
- * about which gate the page must name, not how many times the YAML types it.
- */
-const rootScripts = new Set(
- Object.keys(
- (
- JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as {
- scripts: Record;
- }
- ).scripts,
- ),
-);
-
-/**
- * The job keys a workflow defines, in file order.
- *
- * Scoped to the `jobs:` mapping, because top-level `on:` has two-space children
- * of its own (`push:`, `pull_request:`) that a whole-file scan would read as
- * jobs. Inside `jobs:` the only two-space lines are the job keys themselves:
- * job-level keys sit at four, step bodies deeper still, and every block scalar
- * (`run: |`) is indented past its key, so nothing else can reach column 2.
- */
-function jobKeys(yaml: string, workflowFile: string): string[] {
- const start = yaml.search(/^jobs:[ \t]*$/m);
- expect(start, `${workflowFile} must still have a top-level \`jobs:\` mapping`).toBeGreaterThan(-1);
- const body = yaml.slice(start + 'jobs:'.length);
- // `jobs:` is the last top-level key in both workflows today; stop at the next
- // one regardless.
- const end = body.search(/^[A-Za-z]/m);
- const scoped = end === -1 ? body : body.slice(0, end);
- return [...scoped.matchAll(/^ {2}([a-z0-9][a-z0-9-]*):[ \t]*$/gm)].map((m) => m[1]);
-}
-
-/** One job's YAML block, from its key line up to the next thing at that indent. */
-function jobBlock(yaml: string, key: string, workflowFile: string): string {
- const body = yaml.slice(yaml.search(/^jobs:[ \t]*$/m));
- const at = body.search(new RegExp(`^ {2}${key}:[ \\t]*$`, 'm'));
- expect(at, `${workflowFile} must still define a \`${key}:\` job`).toBeGreaterThan(-1);
- const rest = body.slice(at + 1);
- // A job's own comments are indented four spaces or more; the two-space ones
- // introduce the *next* job, so stopping at any two-space line is right.
- const next = rest.search(/^ {2}\S/m);
- return next === -1 ? rest : rest.slice(0, next);
-}
-
-/** Every `run:` step body in a job block — single-line and block scalar alike. */
-function runSteps(block: string): string[] {
- const lines = block.split('\n');
- const steps: string[] = [];
- for (let i = 0; i < lines.length; i++) {
- const at = lines[i].indexOf('run:');
- // `run:` must be the key of the line, not text inside another value.
- if (at === -1 || !/^[\s-]*$/.test(lines[i].slice(0, at))) continue;
- const value = lines[i].slice(at + 'run:'.length).trim();
- if (!/^[|>][-+]?$/.test(value)) {
- steps.push(value);
- continue;
- }
- const body: string[] = [];
- for (let j = i + 1; j < lines.length; j++) {
- if (lines[j].trim() === '') continue;
- if (lines[j].search(/\S/) <= at) break;
- body.push(lines[j].trim());
- }
- steps.push(body.join('\n'));
- }
- return steps;
-}
-
-/**
- * The first-party commands named in a piece of text — applied to a job's `run:`
- * bodies on one side and to the prose documenting it on the other, so the two
- * sides are compared by the same rule rather than by two spellings of it.
- */
-function firstPartyCommands(text: string): Set {
- const found = new Set();
- // A gate that lives in this repo's `scripts/` tree. The `node ` prefix is not
- // required: the workflows write `node scripts/x.mjs`, the page writes the path.
- //
- // ⛔ Except when the path is a shell-single-quoted ARGUMENT (objectui#8647).
- // This repository writes path LISTS quoted and invocations unquoted — every
- // `on.paths:` entry across `.github/workflows/` is `'scripts/x.mjs'`, and
- // since objectui#8647 the `docs` job's `git diff … -- ` names four
- // scripts the same way, because turbo declares them as inputs to every
- // `build`. Counting those as commands the job RUNS would demand the page
- // document four gates that never execute in that job — a false statement,
- // extracted from a parser rather than from the workflow.
- for (const m of text.matchAll(/scripts\/[\w./-]+\.mjs/g)) {
- const quoted = text[m.index - 1] === "'" && text[m.index + m[0].length] === "'";
- if (!quoted) found.add(m[0]);
- }
- // A root `package.json` script. `install`, `--version`, `exec` and `--filter`
- // are not scripts, so the setup steps need no exemption list.
- for (const m of text.matchAll(/\bpnpm\s+([\w:.-]+)/g)) {
- if (rootScripts.has(m[1])) found.add(`pnpm ${m[1]}`);
- }
- // The build graph, invoked through the task runner instead of a script.
- for (const m of text.matchAll(/\bturbo\s+run\s+([\w:-]+)/g)) found.add(`turbo run ${m[1]}`);
- return found;
-}
-
-/** One unit of the pairing: a job's steps, and the prose this page documents it in. */
-type CommandParity = {
- /** How a failure names the unit — a `ci.yml` job key, or a workflow section. */
- label: string;
- /** First-party commands the job's `run:` steps actually invoke. */
- ran: Set;
- /** First-party commands the page credits it with. */
- named: Set;
-};
-
-/** Pair one job's `run:` steps against the piece of the page that documents it. */
-function commandParity(
- workflowFile: string,
- jobKey: string,
- documentation: string,
- label: string = jobKey,
-): CommandParity {
- const yaml = fs.readFileSync(path.join(workflowDir, workflowFile), 'utf8');
- return {
- label,
- ran: firstPartyCommands(runSteps(jobBlock(yaml, jobKey, workflowFile)).join('\n')),
- named: firstPartyCommands(documentation),
- };
-}
-
-/**
- * objectui#12214: fifteen one-check workflows became steps of ONE job in
- * `repo-gates.yml`, each step named ` — `. A section that documents
- * one of those checks is paired against the steps that carry its check name --
- * plus, for the gates that read built types, the job's one shared build step,
- * which runs on their behalf -- never against the whole job, which would credit
- * every section with every other gate's commands.
- */
-const REPO_GATES_SHARED_BUILD_STEP = 'Build every package the gates below read';
-function checkParity(
- checkName: string,
- documentation: string,
- label: string,
- { withSharedBuild = false }: { withSharedBuild?: boolean } = {},
-): CommandParity {
- const yaml = fs.readFileSync(path.join(workflowDir, 'repo-gates.yml'), 'utf8');
- const steps = jobBlock(yaml, 'repo-gates', 'repo-gates.yml')
- .split(/^(?= {6}- name: )/m)
- .filter(
- (step) =>
- step.startsWith(` - name: ${checkName} — `) ||
- (withSharedBuild && step.startsWith(` - name: ${REPO_GATES_SHARED_BUILD_STEP}`)),
- );
- expect(steps.length, `repo-gates.yml has no step named after ${checkName}`).toBeGreaterThan(0);
- return {
- label,
- ran: firstPartyCommands(runSteps(steps.join('\n')).join('\n')),
- named: firstPartyCommands(documentation),
- };
-}
-
-/** Commands a job runs that the prose documenting it does not name. */
-function undocumentedCommands(units: CommandParity[]): string[] {
- return units.flatMap((u) => [...u.ran].filter((c) => !u.named.has(c)).map((c) => `${u.label}: ${c}`));
-}
-
-/** Commands the prose names that the job it documents does not run. */
-function phantomCommands(units: CommandParity[]): string[] {
- return units.flatMap((u) => [...u.named].filter((c) => !u.ran.has(c)).map((c) => `${u.label}: ${c}`));
-}
-
-/**
- * objectui#3451: the same drift as #3197/#3212, one table lower down and pointing
- * the dangerous way. The `## Core CI Workflow (ci.yml)` section opened with "Seven
- * jobs, all parallel" and its table's seventh row described a `dev-server` job —
- * "guards `apps/dev-server`'s `objectstack.config.ts` against fixture /
- * `@objectstack/spec` drift", running "Every run".
- *
- * The history matters, because the row was wrong in two different ways and only the
- * second is the one you would guess:
- *
- * 2026-05-24 `apps/dev-server` lands, and with it the `dev-server` job.
- * 2026-05-26 `apps/dev-server` is removed. The job stays. `--filter
- * @object-ui/dev-server` now matches no package and exits 0 —
- * green by vacuity, for the next 69 days.
- * 2026-08-03 #3253 (fixing #3212) rewrites this very table and *adds* the
- * `dev-server` row, describing a fixture-drift guard that had not
- * built anything since May. The row was false the day it was written.
- * 2026-08-04 #3325 deletes the vacuous job from `ci.yml`, leaving the row.
- * 2026-08-06 #3451.
- *
- * So this is not only "the YAML moved and the prose lagged". #3253 pinned the
- * *workflow* inventory in both directions and left the *job* table unpinned, and the
- * table drifted within a day. #3197's comment names the direction: understating a
- * gate is annoying, advertising a guardrail the CI does not have is worse than no
- * doc.
- *
- * The count and the table are pinned together because fixing either one alone fixes
- * a snapshot, not the drift. Add or remove a job in `ci.yml` without editing this
- * page and the first test below fails, naming the job in each direction.
- *
- * What this still cannot catch is the 2026-05-26 shape: a job that exists in YAML
- * and does nothing. No amount of doc-to-YAML pinning sees that — only reading what
- * the job runs does.
- */
-describe('ci-cd-pipeline.md — ci.yml job table', () => {
- const ciWorkflow = fs.readFileSync(path.join(workflowDir, 'ci.yml'), 'utf8');
-
- /** Job keys from `ci.yml`, in file order — see `jobKeys` for how the scan is scoped. */
- function ciJobKeys(): string[] {
- return jobKeys(ciWorkflow, 'ci.yml');
- }
-
- /** The `name:` each job reports itself under in the checks list, keyed by job key. */
- function ciJobNames(): Map {
- const names = new Map();
- const start = ciWorkflow.search(/^jobs:[ \t]*$/m);
- const body = ciWorkflow.slice(start);
- for (const key of ciJobKeys()) {
- const at = body.search(new RegExp(`^ {2}${key}:[ \\t]*$`, 'm'));
- const after = body.slice(at);
- const name = after.match(/^ {4}name:[ \t]*(.+?)[ \t]*$/m)?.[1];
- if (name) names.set(key, name.replace(/^['"]|['"]$/g, ''));
- }
- return names;
- }
-
- /** The `## Core CI Workflow (ci.yml)` section, up to the next `##` heading. */
- function coreCiSection(): string {
- const start = doc.indexOf('## Core CI Workflow (`ci.yml`)');
- expect(start, 'the page must still have a "## Core CI Workflow (`ci.yml`)" section').toBeGreaterThan(-1);
- const rest = doc.slice(start + 2);
- const next = rest.search(/^## /m);
- return next === -1 ? rest : rest.slice(0, next);
- }
-
- const JOB_TABLE_HEADER = '| Job key | Appears as | What it runs | When |';
-
- /** The job table's rows in page order: job key, `Appears as`, `What it runs`. */
- function docJobRows(): { key: string; appearsAs: string; runs: string }[] {
- const section = coreCiSection();
- const at = section.indexOf(JOB_TABLE_HEADER);
- expect(at, `the job table must keep the header \`${JOB_TABLE_HEADER}\``).toBeGreaterThan(-1);
- const rows: { key: string; appearsAs: string; runs: string }[] = [];
- for (const cells of markdownTableRows(section.slice(at))) {
- rows.push({ key: cells[0].replace(/`/g, '').trim(), appearsAs: cells[1] ?? '', runs: cells[2] ?? '' });
- }
- return rows;
- }
-
- it('lists exactly the jobs ci.yml defines — in both directions', () => {
- const fromWorkflow = ciJobKeys();
- // A parser that silently matched nothing would make this test vacuously green,
- // which is the failure mode the removed `dev-server` job itself demonstrated.
- expect(fromWorkflow.length, 'the ci.yml `jobs:` parse returned implausibly few keys').toBeGreaterThan(3);
-
- const fromDoc = docJobRows().map((r) => r.key);
- expect(fromDoc.length, 'the job table parse returned implausibly few rows').toBeGreaterThan(3);
-
- const phantom = fromDoc.filter((k) => !fromWorkflow.includes(k));
- const missing = fromWorkflow.filter((k) => !fromDoc.includes(k));
-
- expect(
- phantom,
- `content/docs/guide/ci-cd-pipeline.md's job table has rows for jobs that are NOT in ` +
- `.github/workflows/ci.yml:\n` +
- phantom.map((k) => ` - ${k}`).join('\n') +
- `\n\nDelete the row. A page that advertises a guard CI does not run is worse than no ` +
- `page — objectui#3451: the \`dev-server\` row survived three months after #3325 deleted ` +
- `the job, telling contributors their objectstack.config.ts had drift protection it did not.`,
- ).toEqual([]);
-
- expect(
- missing,
- `.github/workflows/ci.yml defines jobs with no row in the job table of ` +
- `content/docs/guide/ci-cd-pipeline.md:\n` +
- missing.map((k) => ` - ${k}`).join('\n') +
- `\n\nAdd a row (job key, the \`name:\` it appears as in the checks list, what it runs, and ` +
- `when) — an undocumented job is a check contributors get blocked by without knowing it exists.`,
- ).toEqual([]);
- });
-
- it('quotes each job under the name ci.yml gives it', () => {
- // `${{ ... }}` is left as a wildcard: `test` is a matrix job whose name is
- // `Test (shard ${{ matrix.shard }}/4)` and the page sensibly writes `N` for the
- // shard index. Everything outside the expressions must match literally.
- const names = ciJobNames();
- expect(names.size, 'every ci.yml job should declare a `name:`').toBe(ciJobKeys().length);
-
- for (const { key, appearsAs } of docJobRows()) {
- const declared = names.get(key);
- if (!declared) continue; // key mismatch is the previous test's failure to report
- const pattern = new RegExp(
- `^${declared
- .split(/\$\{\{[^}]*\}\}/)
- .map((lit) => lit.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
- .join('.+')}$`,
- );
- expect(
- appearsAs,
- `the job table's "Appears as" for \`${key}\` must match ci.yml's \`name: ${declared}\``,
- ).toMatch(pattern);
- }
- });
-
- it('states no job count, so the number cannot drift away from the table', () => {
- // The #3212 lesson applied one section down: a hand-maintained count drifts by
- // construction and a stale one still reads as authoritative. "Seven jobs, all
- // parallel" outlived the seventh job by three months.
- const section = coreCiSection();
- const counted = section.match(
- /\b(?:one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|\d+)[ \t]+(?:parallel[ \t]+)?jobs\b/i,
- );
- expect(
- counted?.[0],
- `the Core CI section must not hard-code how many jobs ci.yml has (found "${counted?.[0]}") — ` +
- `the table is the list. See the same decision for the workflow count at the top of the page.`,
- ).toBeUndefined();
- });
-
- it('is telling the truth: no CI job builds the retired dev-server fixture', () => {
- // The inverse pin. The page now states outright that there is *no* guard on
- // `objectstack.config.ts` / `@objectstack/spec` drift. If anyone restores such a
- // job, this fails and points at the paragraph that denies it exists.
- expect(ciJobKeys()).not.toContain('dev-server');
- expect(
- ciWorkflow,
- 'a dev-server fixture build is back in ci.yml — update the "What is not in `ci.yml`" ' +
- 'section, which currently tells readers no such guard exists',
- ).not.toMatch(/@object-ui\/dev-server/);
- // Reflow-tolerant, and asserted as a boolean so a failure prints the reason
- // rather than diffing the entire page into the terminal.
- expect(
- /Today there is no `apps\/dev-server` and no such job/.test(doc.replace(/\s+/g, ' ')),
- 'the "What is not in `ci.yml`" section must keep stating outright that neither the ' +
- '`dev-server` job nor `apps/dev-server` exists. The table pin above catches a restored ' +
- 'job with no row; this catches a restored job whose row was added while this paragraph ' +
- 'still denies it (objectui#3451).',
- ).toBe(true);
- });
-
- /**
- * objectui#3653: every pin above judges `ci.yml` at JOB granularity — the set of
- * job keys, the `name:` each one reports under, the absence of a count. None of
- * them reads what a job *runs*, so a `run:` step added to an existing job left
- * this whole file green. Two such steps had landed unlisted: `pnpm
- * check:i18n-keys` (objectui#3530, PR #3547) and `pnpm check:i18n-drift`
- * (objectui#3650, PR #3659) both ran in the `type-check` job while its row on the
- * page still listed five commands.
- *
- * The rule, both of its directions, and what counts as a command all live at
- * module scope now (objectui#8015) — `lint.yml`'s section had the identical
- * drift and is pinned by the same code at the bottom of this file. Only the
- * `ci.yml`-specific part is here: the documentation for each job is that job's
- * `What it runs` table cell.
- *
- * Whether a given step still EXISTS in `ci.yml` is pinned where that step was
- * introduced — `check-i18n-call-site-keys.test.ts` and
- * `check-i18n-en-drift.test.ts` each hold their own, as do
- * `scripts-type-check.test.ts` and `vitest-setup-type-check.test.ts`. This block
- * does not repeat those assertions; it pins the *pairing* between the YAML and
- * this page, which is the part nothing owned.
- */
- describe('what each job runs', () => {
- /** `job key -> commands it actually runs`, and the same from the page's table. */
- function commandsByJob(): CommandParity[] {
- return docJobRows().map((row) => commandParity('ci.yml', row.key, row.runs));
- }
-
- it('names every first-party command the job actually runs', () => {
- const jobs = commandsByJob();
-
- // A parser that matched nothing would make both directions vacuously green —
- // the exact failure the `dev-server` job demonstrated one level up.
- const ran = jobs.reduce((n, j) => n + j.ran.size, 0);
- expect(ran, 'the ci.yml `run:` parse found implausibly few first-party commands').toBeGreaterThan(8);
-
- const missing = undocumentedCommands(jobs);
-
- expect(
- missing,
- `.github/workflows/ci.yml runs commands that the job table in ` +
- `content/docs/guide/ci-cd-pipeline.md does not name:\n` +
- missing.map((m) => ` - ${m}`).join('\n') +
- `\n\nAdd each one to that job's "What it runs" cell, in the order ci.yml runs it. ` +
- `A gate nobody wrote down is a build failure contributors meet without knowing what ` +
- `produced it — objectui#3653: two locale gates ran in \`type-check\` unlisted, because ` +
- `the pins on this page read job keys and job names but never read the steps.`,
- ).toEqual([]);
- });
-
- it('reads a quoted pathspec as an argument and an unquoted path as a command', () => {
- // The control for the exclusion above (objectui#8647). Without the first
- // assertion the exclusion is unverified; without the second it could
- // silently swallow every real gate and leave both directions green.
- const ran = commandsByJob().flatMap((j) => [...j.ran]);
- expect(
- ran,
- 'a `git diff` pathspec entry is not a command the job runs',
- ).not.toContain('scripts/check-dist-completeness.mjs');
- expect(
- ran,
- 'an unquoted `node scripts/…` invocation must still be counted',
- ).toContain('scripts/check-doc-expression-carriage.mjs');
- });
-
- it('credits no job with a first-party command it does not run', () => {
- const jobs = commandsByJob();
-
- const named = jobs.reduce((n, j) => n + j.named.size, 0);
- expect(named, 'the job table parse found implausibly few commands in "What it runs"').toBeGreaterThan(8);
-
- const phantom = phantomCommands(jobs);
-
- expect(
- phantom,
- `content/docs/guide/ci-cd-pipeline.md's job table credits jobs with commands that ` +
- `.github/workflows/ci.yml does not run there:\n` +
- phantom.map((p) => ` - ${p}`).join('\n') +
- `\n\nEither the step was removed and the cell is stale, or the command runs in a ` +
- `different job and belongs in that row. A "What it runs" cell reads as this job's ` +
- `gate list, so naming a command for contrast inside it makes the page claim a ` +
- `guardrail — the objectui#3451 mistake, one level down.`,
- ).toEqual([]);
- });
- });
-});
-
-/**
- * objectui#8015: the same pairing, applied to the section this page's other pins
- * could not reach.
- *
- * `lint.yml` is a REQUIRED context on `pull_request` and `merge_group` alike, and
- * every gate in it blocks a merge. The only thing holding its section here was the
- * workflow-inventory pin at the top of the file, which requires a `##` heading
- * naming the file and says nothing whatsoever about the heading's contents — so the
- * step list underneath it could drift indefinitely and every test on this page
- * stayed green. It had: measured on `4b4d35a7d`, the section named three of the
- * seven first-party commands the `lint` job runs, and the four it omitted —
- * `check-entry-guard.mjs`, `check-upstream-port-parity.mjs`,
- * `check-bash32-floor.mjs` and `check-cross-repo-closer-outcome.mjs` — are all
- * blocking gates. Three of them had been missing since they landed; the fourth
- * (`check-bash32-floor.mjs`, PR #8016) arrived while the card was open, which is
- * the drift rate this pin exists to absorb.
- *
- * The neighbouring gap was demonstrated rather than argued: on the card before this
- * one, a sentence in this same document was replaced with an obvious falsehood and
- * the eleven test files that read the page were re-run — 404/404 green, plus
- * `check-doc-links` at exit 0. Nothing looked at this page's contents beyond the
- * `ci.yml` table.
- *
- * The prose SHAPE differs from `ci.yml`'s (bullets and paragraphs, not a table
- * cell), which is exactly why the rule was hoisted to module scope instead of
- * copied: `commandParity` takes whatever text documents a job, so the unit here is
- * the whole `## Lint (lint.yml)` section against the single `lint` job. Reading the
- * whole section rather than only the bullet list is deliberate — the paragraphs
- * below the bullets discuss `pnpm lint` and `pnpm check` too, and a rule that read
- * only the list would call those phantoms.
- */
-describe('ci-cd-pipeline.md — lint.yml step list', () => {
- const lintWorkflow = fs.readFileSync(path.join(workflowDir, 'lint.yml'), 'utf8');
- const LINT_HEADING = '## Lint (`lint.yml`)';
-
- /** The `## Lint (lint.yml)` section, up to the next `##` heading. */
- function lintSection(): string {
- const start = doc.indexOf(LINT_HEADING);
- expect(start, `the page must still have a "${LINT_HEADING}" section`).toBeGreaterThan(-1);
- const rest = doc.slice(start + 2);
- const next = rest.search(/^## /m);
- return next === -1 ? rest : rest.slice(0, next);
- }
-
- function lintUnits(): CommandParity[] {
- return [commandParity('lint.yml', 'lint', lintSection(), 'lint.yml `lint`')];
- }
-
- it('describes the only job lint.yml defines', () => {
- // The unit below covers the `lint` job and nothing else. A second job added to
- // this workflow would run gates that no assertion here reads and no section
- // here documents, so it has to come through this test first.
- expect(
- jobKeys(lintWorkflow, 'lint.yml'),
- 'lint.yml no longer defines exactly one `lint` job. The section pinned below ' +
- 'documents that job alone, so a new job needs its own documentation and its own ' +
- 'unit in `lintUnits()` — otherwise its gates are unpinned and undocumented at once.',
- ).toEqual(['lint']);
- });
-
- it('names every first-party command the lint job actually runs', () => {
- const units = lintUnits();
-
- // A parser that matched nothing would make both directions vacuously green.
- // The floor is a control on the matcher, not a ratchet on the gate count.
- const ran = units.reduce((n, u) => n + u.ran.size, 0);
- expect(ran, 'the lint.yml `run:` parse found implausibly few first-party commands').toBeGreaterThan(4);
-
- const missing = undocumentedCommands(units);
-
- expect(
- missing,
- `.github/workflows/lint.yml runs commands that the "${LINT_HEADING}" section of ` +
- `content/docs/guide/ci-cd-pipeline.md does not name:\n` +
- missing.map((m) => ` - ${m}`).join('\n') +
- `\n\nAdd each one to that section, in the order lint.yml runs it. Every gate in this ` +
- `job blocks a merge on a required check, so one nobody wrote down is a build failure ` +
- `contributors meet with no way to learn from this page what produced it — objectui#8015.`,
- ).toEqual([]);
- });
-
- it('credits the lint job with no first-party command it does not run', () => {
- const units = lintUnits();
-
- const named = units.reduce((n, u) => n + u.named.size, 0);
- expect(named, 'the Lint section parse found implausibly few commands').toBeGreaterThan(4);
-
- const phantom = phantomCommands(units);
-
- expect(
- phantom,
- `the "${LINT_HEADING}" section of content/docs/guide/ci-cd-pipeline.md names commands ` +
- `that .github/workflows/lint.yml does not run:\n` +
- phantom.map((p) => ` - ${p}`).join('\n') +
- `\n\nEither the step was removed and the prose is stale, or the command runs in a ` +
- `different workflow and belongs in that section. This section reads as the \`lint\` ` +
- `job's gate list, so naming a command inside it makes the page claim a guardrail — ` +
- `the objectui#3451 mistake, in prose instead of a table.`,
- ).toEqual([]);
- });
-});
-
-/**
- * objectui#4170: the `## Merge Queue` section's other current-state claim — the
- * "Some contexts can never be required, structurally" bullet, which names four
- * workflows and, for each, quotes the live YAML property that makes it
- * unrequirable.
- *
- * All four were TRUE when this landed, so this pins prose that was not wrong; it
- * is here because of what happens next. objectui#4154 had just converged the
- * sibling paragraph one level up onto a pointer, for the reason that prose
- * nothing reads goes stale silently — and this bullet is that shape with an
- * expiry date already written into the YAML it describes: `live-e2e.yml`'s own
- * header says its `continue-on-error: true` comes off once the lane has run
- * clean long enough to trust. The day that lands, this page states that a lane
- * which now blocks merges can *never* block them, which is the direction #3197
- * and #3451 both name as the dangerous one — advertising or denying a guardrail
- * wrongly is worse than saying nothing, because people stop checking.
- *
- * The bullet is kept whole rather than replaced by a pointer (the choice that
- * fit #4154 and does not fit here): its four entries teach four *different*
- * structural reasons a context cannot be required — an inverse path filter, an
- * ordinary one, a job that cannot fail, and a trigger that only fires after the
- * merge. That is pedagogy, not an inventory, and a pointer would delete it.
- *
- * Every expectation below is READ OUT OF the workflow — never a second copy of
- * the value written here (the objectui#4150 derived-expectation pattern). The
- * page is then checked against what the YAML actually says, so a path list or a
- * trigger type cannot be changed in one place and stay green in the other.
- *
- * SCOPE, stated so it is not mistaken for more — this pins EXAMPLES TRUE, not a
- * CENSUS:
- *
- * - Nothing here scans `.github/workflows/` for other structurally
- * unrequirable workflows, and a fifth one arriving must NOT turn this red.
- * The bullet says "Some contexts", and it is right to: it exists to teach
- * the four shapes, not to enumerate their instances.
- * - What IS checked in both directions is the page's own claims. A line added
- * to that bullet needs an entry here, or it is unpinned prose again — this
- * issue verbatim — and an entry here whose line has left the page fails too,
- * so a claim cannot be quietly dropped while its pin reports green.
- *
- * THE `live-e2e.yml` RED IS EXPECTED, and it is not a bug to be worked around.
- * When `continue-on-error` comes off that job, `structuralBlocks` fails naming
- * `live-e2e.yml`. The fix is to DELETE that line from the bullet and its entry
- * from the map below — the lane has become requirable, so the page must stop
- * saying otherwise. Do not soften the line ("mostly informational"), and do not
- * relax the check to keep it green: a page that hedges about whether a gate can
- * block you is the failure this file was opened for.
- *
- * `performance-budget.yml`'s line quotes no value (it says "an ordinary path
- * filter" and names no globs), so only the existence of the property is pinned
- * there. The asymmetry is deliberate: `quotes` derives from the YAML, and a
- * claim that quotes nothing has nothing to derive.
- *
- * The small YAML-block helpers below are local copies of the ones in
- * `merge-queue-reporting.test.ts`. Data is never copied in this repository; a
- * twenty-line indentation scanner is not data, and neither file is a library.
- */
-const readWorkflow = (file: string): string => fs.readFileSync(path.join(workflowDir, file), 'utf8');
-
-/**
- * A workflow's YAML with whole-line comments removed. Load-bearing, not
- * cosmetic: `live-e2e.yml`'s header argues at length about `continue-on-error`
- * — more so since objectui#8084 took the flag off its job and wrote the
- * measurement into the header — and `changeset-guard.yml`'s explains why
- * `.changeset/**` is filtered the way it is, so a scan that counted comments
- * would report properties from prose. ⛔ No line count is stated here on
- * purpose: a hand-written count of prose lines is a claim nothing asserts.
- */
-function withoutComments(yaml: string): string {
- return yaml
- .split('\n')
- .filter((line) => !/^\s*#/.test(line))
- .join('\n');
-}
-
-/** A top-level block (`on:`, `jobs:`) up to the next top-level key. */
-function topLevelBlock(yaml: string, key: string): string {
- const at = yaml.search(new RegExp(`^${key}:`, 'm'));
- if (at === -1) return '';
- const rest = yaml.slice(at);
- const firstLineEnd = rest.indexOf('\n') + 1;
- if (firstLineEnd === 0) return rest;
- const after = rest.slice(firstLineEnd);
- const next = after.search(/^[A-Za-z]/m);
- return next === -1 ? rest : rest.slice(0, firstLineEnd + next);
-}
-
-/** One two-space child of `on:` / `jobs:`, up to the next child at that indent. */
-function nestedBlock(block: string, key: string): string {
- const at = block.search(new RegExp(`^ {2}${key}:`, 'm'));
- if (at === -1) return '';
- const rest = block.slice(at);
- const firstLineEnd = rest.indexOf('\n') + 1;
- if (firstLineEnd === 0) return rest;
- const after = rest.slice(firstLineEnd);
- const next = after.search(/^ {2}\S/m);
- return next === -1 ? rest : rest.slice(0, firstLineEnd + next);
-}
-
-/** The two-space child keys of an `on:` block — the events the workflow subscribes. */
-const triggerKeys = (onBlock: string): string[] =>
- [...onBlock.matchAll(/^ {2}([a-z_][a-z_-]*):/gm)].map((m) => m[1]);
-
-/** `- 'pattern'` entries — the shape a `paths:` list is written in. */
-const listEntries = (block: string): string[] =>
- [...block.matchAll(/^\s*-\s*'?([^'\n]+?)'?\s*$/gm)].map((m) => m[1]);
-
-/** A trigger's `types:` list, inline (`types: [closed]`) or block. */
-function declaredTypes(trigger: string): string[] {
- const inline = trigger.match(/^\s*types:\s*\[([^\]]*)\]\s*$/m);
- if (inline) {
- return inline[1]
- .split(',')
- .map((t) => t.trim().replace(/^['"]|['"]$/g, ''))
- .filter(Boolean);
- }
- const at = trigger.search(/^\s*types:\s*$/m);
- if (at === -1) return [];
- const types: string[] = [];
- for (const line of trigger.slice(at).split('\n').slice(1)) {
- const entry = line.match(/^\s*-\s*['"]?([\w-]+)['"]?\s*$/);
- if (!entry) break;
- types.push(entry[1]);
- }
- return types;
-}
-
-/** The `paths:` entries on a workflow's `pull_request` trigger, if it has any. */
-function pullRequestPaths(file: string): string[] {
- const trigger = nestedBlock(topLevelBlock(withoutComments(readWorkflow(file)), 'on'), 'pull_request');
- const at = trigger.search(/^ {4}paths:\s*$/m);
- if (at === -1) return [];
- return listEntries(trigger.slice(at));
-}
-
-/**
- * The `paths-ignore:` globs a workflow's `pull_request` trigger declares — the
- * exclusion half of the same structural property. A context the filter keeps
- * from being created is a context a required check waits on forever, exactly as
- * for `paths:`; `ci.yml` keeps its own `paths-ignore` on the push trigger ONLY
- * for that reason (objectui#3523).
- */
-function pullRequestPathsIgnore(file: string): string[] {
- const trigger = nestedBlock(topLevelBlock(withoutComments(readWorkflow(file)), 'on'), 'pull_request');
- const at = trigger.search(/^ {4}paths-ignore:\s*$/m);
- if (at === -1) return [];
- return listEntries(trigger.slice(at));
-}
-
-interface StructuralBlock {
- /** The property, named the way a failure message should name it. */
- readonly property: string;
- /** `null` while the page's claim still holds; otherwise what the YAML says now. */
- broken(): string | null;
- /** Values the page's line must still quote, READ FROM the YAML. */
- quotes(): string[];
- /** What the fix is when it breaks — different for each, so it is written per claim. */
- readonly whenItBreaks: string;
-}
-
-/**
- * `filename -> the YAML property that makes the bullet's claim about it true`.
- *
- * Not a `NEVER_REQUIRABLE` inventory, and the page does not point at it: each
- * entry is a CHECKER for a claim the page makes in its own words. "Can this
- * context be required?" is still a question about repository settings that no
- * test here can read — what is checked is the narrower, mechanical thing the
- * page actually asserts about each workflow's YAML.
- */
-const STRUCTURAL_BLOCKS = new Map([
- [
- 'changeset-guard.yml',
- {
- property: "an inverse path filter — `paths:` on the `pull_request` trigger",
- broken() {
- const paths = pullRequestPaths('changeset-guard.yml');
- return paths.length > 0
- ? null
- : 'its `pull_request` trigger no longer declares `paths:`, so it now reports on ' +
- 'pull requests that touch no changeset — which is what would make it requirable';
- },
- quotes: () => pullRequestPaths('changeset-guard.yml'),
- whenItBreaks:
- 'If the filter was removed deliberately, this workflow reports on every pull request ' +
- 'and belongs in the paragraph ABOVE the bullet instead — the one about workflows that ' +
- 'must subscribe `merge_group` — not in the list of contexts that cannot be required.',
- },
- ],
- [
- 'performance-budget.yml',
- {
- property: 'a path filter — `paths:` on the `pull_request` trigger',
- broken() {
- const paths = pullRequestPaths('performance-budget.yml');
- return paths.length > 0
- ? null
- : 'its `pull_request` trigger no longer declares `paths:`, so Bundle Analysis now ' +
- 'reports on every pull request and the page is denying a guardrail that exists';
- },
- // The page's line names no globs, so there is nothing derived to hold it to.
- quotes: () => [],
- whenItBreaks:
- 'Same as above: a filterless Bundle Analysis is a context that reports everywhere, so ' +
- 'it stops being an example of this rule and becomes an example of the previous one.',
- },
- ],
- [
- 'live-e2e.yml',
- {
- property: '`paths-ignore:` on the `pull_request` trigger',
- broken() {
- const ignored = pullRequestPathsIgnore('live-e2e.yml');
- return ignored.length > 0
- ? null
- : 'its `pull_request` trigger no longer declares `paths-ignore:`, so Live E2E now ' +
- 'reports on every pull request and the page is denying a guardrail that exists';
- },
- quotes: () => pullRequestPathsIgnore('live-e2e.yml'),
- whenItBreaks:
- 'THIS LINE HAS ALREADY OUTLIVED ONE PROPERTY, which is the thing to read before editing ' +
- 'it again. It used to quote `continue-on-error: true` on the job, on the reasoning that ' +
- 'a job which cannot fail cannot gate anything. objectui#8084 measured that reasoning ' +
- 'false in both halves: the flag left the JOB and its CHECK RUN red — job 101442890465 ' +
- 'and check run `Live E2E (informational)` both reported `failure` on run 34017174769 — ' +
- 'so it never made the context unrequirable; it only inverted the aggregate, which is the ' +
- 'defect that card was filed for. Taking it off therefore did NOT promote the lane, and ' +
- 'this entry was re-pointed rather than deleted: the `paths-ignore:` filter quoted here is ' +
- 'what still keeps the context from being created on a docs-only pull request, and a ' +
- 'required check cannot survive a context that is never created. Retire this entry when ' +
- 'live-e2e.yml carries no such property at all — never because one was replaced by ' +
- 'another, and never by softening the line in place.',
- },
- ],
- [
- 'cross-repo-issue-closer.yml',
- {
- property: 'every trigger restricted to `types: [closed]`, plus the job gate on `merged == true`',
- broken() {
- const yaml = withoutComments(readWorkflow('cross-repo-issue-closer.yml'));
- const on = topLevelBlock(yaml, 'on');
- const keys = triggerKeys(on);
- if (keys.length === 0) return 'no trigger could be parsed out of its `on:` block';
-
- const preMerge = keys.filter((key) => {
- const types = declaredTypes(nestedBlock(on, key));
- return types.length === 0 || types.some((t) => t !== 'closed');
- });
- if (preMerge.length > 0) {
- return `it now subscribes ${preMerge.map((k) => `\`${k}\``).join(', ')} without ` +
- 'restricting to `types: [closed]`, so the workflow can start while a pull request ' +
- 'is still open';
- }
- return /if:.*github\.event\.pull_request\.merged\s*==\s*true/.test(yaml)
- ? null
- : 'the job no longer gates on `github.event.pull_request.merged == true`, so a pull ' +
- 'request CLOSED WITHOUT MERGING now runs it too — "only after a merge" is the ' +
- 'half of this claim that gate carries';
- },
- quotes() {
- const on = topLevelBlock(withoutComments(readWorkflow('cross-repo-issue-closer.yml')), 'on');
- // Both halves the page's line names: which event, and which types of it.
- return [...triggerKeys(on), ...triggerKeys(on).flatMap((key) => declaredTypes(nestedBlock(on, key)))];
- },
- whenItBreaks:
- 'A trigger that can fire before the merge makes this a context that reports on open ' +
- 'pull requests, which is the opposite of what the line says. Rewrite the line to match ' +
- 'the new trigger, or move the workflow out of the bullet.',
- },
- ],
-]);
-
-/** The bullet, and the one claim each of its nested lines makes. */
-interface StructuralClaim {
- /** The workflow file the line names. */
- readonly workflow: string;
- /** The line itself, joined onto one string — for `quotes` and for messages. */
- readonly line: string;
-}
-
-const CLAIM_LEAD_IN = /^- \*\*Some contexts can never be required, structurally\*\*/m;
-
-/**
- * The nested lines under the bullet, one per claim.
- *
- * Parsed rather than matched against a fixed shape so that reflowing the prose
- * is free: the bullet block runs from its lead-in to the next line starting at
- * column 0, and inside it a claim starts at each two-space `- ` and continues
- * through its own continuation lines.
- */
-function structuralClaims(): StructuralClaim[] {
- const at = doc.search(CLAIM_LEAD_IN);
- if (at === -1) return [];
-
- const lines = doc.slice(at).split('\n');
- const block: string[] = [lines[0]];
- for (const line of lines.slice(1)) {
- if (line.trim() !== '' && !line.startsWith(' ')) break;
- block.push(line);
- }
-
- const claimLines: string[] = [];
- let current: string[] | null = null;
- for (const line of block.slice(1)) {
- if (/^ {2}- /.test(line)) {
- if (current) claimLines.push(current.join(' '));
- current = [line.trim()];
- } else if (line.trim() === '') {
- // A blank line ends the claim. The bullet closes with a paragraph about
- // the pin itself, indented into the same list item and naming
- // `live-e2e.yml` again; swallowed into the last claim it would read as a
- // second claim about whichever workflow it happened to mention.
- if (current) claimLines.push(current.join(' '));
- current = null;
- } else if (current) {
- current.push(line.trim());
- }
- }
- if (current) claimLines.push(current.join(' '));
-
- return claimLines.flatMap((line) => {
- const named = [
- ...new Set([...line.matchAll(/(? m[1])),
- ];
- return named.map((workflow) => ({ workflow, line }));
- });
-}
-
-describe('ci-cd-pipeline.md — contexts that can never be required (#4170)', () => {
- it('still carries the bullet, with one workflow named per claim', () => {
- expect(
- doc.search(CLAIM_LEAD_IN),
- 'the "Some contexts can never be required, structurally" bullet is gone from ' +
- 'content/docs/guide/ci-cd-pipeline.md. Everything below pins that bullet, so removing it ' +
- 'turns this whole block vacuously green — the failure mode this page keeps meeting ' +
- '(objectui#3451). If the bullet was deliberately retired, delete these tests with it.',
- ).toBeGreaterThan(-1);
-
- const claims = structuralClaims();
- expect(
- claims.length,
- 'the bullet parsed to no claims at all. Each entry must be a nested ` - ` line naming ' +
- 'the workflow file it is about (e.g. "`live-e2e.yml`"); a claim that names no file ' +
- 'cannot be pinned to anything, which is what objectui#4170 was filed about.',
- ).toBeGreaterThan(0);
- });
-
- it('pins every workflow the bullet claims', () => {
- // The page → map direction. A fifth structurally unrequirable workflow may
- // exist unmentioned (this bullet is examples, not a census), but a fifth
- // LINE here is a new claim, and an unpinned claim is exactly the defect.
- const unpinned = structuralClaims()
- .filter((claim) => !STRUCTURAL_BLOCKS.has(claim.workflow))
- .map((claim) => `${claim.workflow} — "${claim.line.slice(0, 80)}…"`);
-
- expect(
- unpinned,
- `The "can never be required, structurally" bullet makes claims about workflows that ` +
- `nothing in this file checks:\n` +
- unpinned.map((u) => ` - ${u}`).join('\n') +
- `\n\nAdd an entry to STRUCTURAL_BLOCKS naming the YAML property that makes the claim ` +
- `true, and read that property out of the workflow rather than restating it here. A ` +
- `claim on this page that no assertion reads is true only until someone edits the YAML, ` +
- `and nothing then says so (objectui#4170).`,
- ).toEqual([]);
- });
-
- it.each([...STRUCTURAL_BLOCKS.keys()])('%s — the page still makes the claim this pins', (file) => {
- // The map → page direction, so a claim cannot be dropped while its pin keeps
- // reporting green on a property nobody documents any more.
- expect(
- structuralClaims().map((c) => c.workflow),
- `STRUCTURAL_BLOCKS pins a claim about ${file}, but the "can never be required, ` +
- `structurally" bullet in content/docs/guide/ci-cd-pipeline.md no longer names it.\n\n` +
- `If the line was removed because the property changed, remove this entry too — that is ` +
- `the intended sequence for live-e2e.yml. If the line was removed for space, put it ` +
- `back: each of these teaches a different structural reason a context cannot be ` +
- `required, which is why the bullet was kept whole rather than replaced by a pointer ` +
- `(objectui#4170).`,
- ).toContain(file);
- });
-
- it.each([...STRUCTURAL_BLOCKS.entries()])('%s — the property the page quotes still holds', (file, block) => {
- const broken = block.broken();
- expect(
- broken,
- `content/docs/guide/ci-cd-pipeline.md says ${file} can never produce a required ` +
- `context, because of ${block.property}. That is no longer what the YAML says: ${broken}.` +
- `\n\n${block.whenItBreaks}`,
- ).toBeNull();
- });
-
- it.each([...STRUCTURAL_BLOCKS.entries()])('%s — the page quotes the values the YAML declares', (file, block) => {
- // Skipped when the property itself is gone: that failure belongs to the test
- // above, and reporting it twice would say a page is quoting a value wrong
- // when the value no longer exists at all.
- if (block.broken() !== null) return;
-
- const claim = structuralClaims().find((c) => c.workflow === file);
- if (!claim) return; // reported by the presence test above
-
- const values = block.quotes();
- const missing = values.filter((value) => value !== '' && !claim.line.includes(value));
-
- expect(
- missing,
- `The ${file} line of the "can never be required, structurally" bullet no longer quotes ` +
- `what ${file} actually declares:\n` +
- missing.map((v) => ` - ${v}`).join('\n') +
- `\n\nThese are read out of the workflow, so the page is behind the YAML, not the other ` +
- `way round. Update the line to quote the live value. (Quoting the property is what ` +
- `makes the claim checkable at all — objectui#4170 was filed because a bullet quoting ` +
- `four YAML properties was pinned to none of them.)`,
- ).toEqual([]);
- });
-});
-
-/**
- * objectui#7689 — the page's live-e2e section states a pin rule that nothing read.
- *
- * `content/docs/guide/ci-cd-pipeline.md` says the backend pins "must match the
- * `@objectstack/spec` version in `pnpm-lock.yaml` — bump both in the same PR, or the
- * run proves nothing", and `e2e/live/ci/backend.env` repeats the same MUST in its own
- * header. Both were true statements about what a reader should do and false statements
- * about what CI checked: `git grep -l backend.env scripts/ .github/ e2e/` reached only
- * the workflow that caches on its hash and the script that sources it. Nothing compared
- * the two numbers, so the pin sat at `17.0.0-rc.2` against a lockfile resolving `17.2.0`
- * for two minor versions while `Live E2E (informational)` reported green.
- *
- * That is the shape this file exists for, and the worst instance of it yet. The size
- * regime in #3197 advertised a guardrail CI did not have; here the lane's OWN contract
- * says a mismatched pair "proves nothing", so every green run it produced over those two
- * minors was a check whose documentation declared it meaningless. A stale constant is a
- * chore; a green light its own spec disclaims is worse than a red one.
- *
- * ## What this asserts, and the one half it deliberately does not
- *
- * RETIRED with the import into objectstack (objectstack#25803): the comparison of
- * `OBJECTSTACK_VERSION` against the `@objectstack/spec` version the lockfile resolves.
- * The console now builds against the workspace's own `@objectstack/spec`, a `link:` in
- * the one lockfile at the monorepo root, so the lockfile carries no spec version for the
- * pin to match. The guard's own floor named that case ("the dependency is genuinely gone
- * — in which case the live lane has nothing to pin and this block should go"), and the
- * page's sentence went with it.
- *
- * STILL ASSERTED, since objectui#7964: that the file's other pin is GONE. `OBJECTSTACK_REF`
- * used to be a second, hand-moved sha here, carrying a MUST — "always the commit the
- * `@objectstack/cli@${OBJECTSTACK_VERSION}` release tag points at" — that nothing could
- * check, because reading that tag needs the objectstack repository over the network and
- * this unit lane has not got it. That was a true statement about what a reader should do
- * and, again, an unenforceable one: the same shape #7689 is about, one file down.
- *
- * The fix was not another check. It was to delete the second value: `start-backend.sh`
- * now RESOLVES the commit at boot from the release tag named by `OBJECTSTACK_VERSION`
- * (`git ls-remote --tags`, peeled `^{}` sha preferred) and refuses to start when the tag
- * does not resolve to a 40-character sha. The lane that consumes the commit is the lane
- * that can reach the repository, and it always could. So the pair the header describes
- * cannot disagree by construction, and there is nothing left here to hand-move.
- *
- * What this file pins about that half is therefore the ABSENCE and the DERIVATION, by
- * content: no `OBJECTSTACK_REF=` key in backend.env, and the resolution + refusal still in
- * start-backend.sh. Either one alone would be vacuous — an absent key is fine only while
- * something derives the value, and a derivation is only load-bearing while no pin
- * overrides it.
- *
- * ## Anti-vacuity
- *
- * The derivation half is pinned by content in both directions: the absent key below is
- * only a design while start-backend.sh still derives the value, and the derivation only
- * carries weight while no key overrides it.
- */
-const backendEnvPath = path.join(repoRoot, 'e2e/live/ci/backend.env');
-const startBackendPath = path.join(repoRoot, 'e2e/live/ci/start-backend.sh');
-const backendEnv = fs.readFileSync(backendEnvPath, 'utf8');
-const startBackend = fs.readFileSync(startBackendPath, 'utf8');
-
-const readEnvKey = (key: string): string | null =>
- backendEnv.match(new RegExp('^' + key + '=(.+)$', 'm'))?.[1].trim() ?? null;
-
-describe('ci-cd-pipeline.md — live-e2e backend pin (#7689)', () => {
- // Retired with the import into objectstack (objectstack#25803): `still carries the
- // sentence this block enforces`, `resolves exactly one @objectstack/spec version to
- // compare against` and `pins OBJECTSTACK_VERSION to the version the lockfile resolves`.
- // The lockfile resolves the spec as a workspace `link:` and records no version, so the
- // pair they compared no longer exists; see the docblock above.
-
- // Retired here, deliberately, with objectui#7964: `keeps OBJECTSTACK_REF in the one shape
- // start-backend.sh can fetch`. It asserted that a hand-moved sha was 40 hex characters —
- // the only half of that pin a lane with no network could read. There is no hand-moved sha
- // any more, so the shape pin has nothing to hold; the three below hold what replaced it.
- // ⛔ Do not restore it by re-adding the key: a pin that overrides the derivation brings
- // back the exact pair that could silently disagree.
-
- it('declares no OBJECTSTACK_REF — the commit is derived, not pinned', () => {
- expect(
- readEnvKey('OBJECTSTACK_REF'),
- `${path.relative(repoRoot, backendEnvPath)} declares an OBJECTSTACK_REF again. That ` +
- 'key was retired by objectui#7964: it was a second, hand-moved sha whose stated MUST ' +
- '— always the commit the `@objectstack/cli@$OBJECTSTACK_VERSION` release tag points ' +
- 'at — nothing could check, and a pair that CAN disagree eventually does (it is the ' +
- 'same failure #7689 found in the version half). start-backend.sh resolves the commit ' +
- 'from the tag at boot instead, so the app source and the published packages come ' +
- 'from one release by construction.\n\n' +
- 'If the derivation genuinely cannot serve some case, that is a decision to take on ' +
- 'the record — reintroducing the pin here restores the drift, and this lane is ' +
- '`informational`, so nothing else would notice.',
- ).toBeNull();
- });
-
- it('derives the commit from the @objectstack/cli release tag in start-backend.sh', () => {
- const rel = path.relative(repoRoot, startBackendPath);
-
- // Pinned by CONTENT, not by behaviour: this lane cannot run the script (it needs the
- // network the whole #7964 argument turns on). What it can read is that the resolution
- // is still there and still keyed off OBJECTSTACK_VERSION — the two things whose loss
- // would leave the absent key above vacuous.
- expect(
- /git ls-remote --tags/.test(startBackend),
- `${rel} no longer resolves the release tag with \`git ls-remote --tags\`. The test ` +
- 'above requires backend.env to carry NO OBJECTSTACK_REF, on the understanding that ' +
- 'this script derives it. Without a resolution here, that absence is not a design — ' +
- 'it is a missing value, and the lane fetches nothing.',
- ).toBe(true);
-
- expect(
- /refs\/tags\/\$OBJECTSTACK_TAG/.test(startBackend) &&
- /OBJECTSTACK_TAG="@objectstack\/cli@\$OBJECTSTACK_VERSION"/.test(startBackend),
- `${rel} no longer builds the tag it resolves from OBJECTSTACK_VERSION as ` +
- '`@objectstack/cli@$OBJECTSTACK_VERSION`. That coupling is the entire guarantee: it ' +
- 'is what makes the checked-out app source and the installed published packages the ' +
- 'same release. A tag derived from anything else — a branch, a literal, another ' +
- "package's tag — reopens the gap objectui#7964 closed.",
- ).toBe(true);
-
- // The refusal is half the ruling: `git ls-remote` prints nothing and exits 0 for a tag
- // that does not exist, so without a shape check the script would carry an empty ref into
- // `git fetch --depth 1 origin ""` and fail 300 seconds later, in a log nobody reads.
- expect(
- /\[\[ ! "\$OBJECTSTACK_REF" =~ \^\[0-9a-f\]\{40\}\$ \]\]/.test(startBackend),
- `${rel} no longer refuses to start when the release tag fails to resolve to a ` +
- '40-character sha. `git ls-remote` reports a missing tag as empty output and exit 0, ' +
- 'so this check is the only thing standing between a typo in OBJECTSTACK_VERSION and ' +
- 'a 300-second timeout with no explanation. Keep a refusal that names the tag.',
- ).toBe(true);
- });
-
- it('documents the derivation in backend.env, where the pin used to be', () => {
- expect(
- /no OBJECTSTACK_REF key here/.test(backendEnv) && /DERIVED at\s*\n#\s*boot/.test(backendEnv),
- `${path.relative(repoRoot, backendEnvPath)} no longer explains that the showcase-app ` +
- 'commit is derived at boot rather than pinned. The key is absent from this file; a ' +
- 'reader who finds no OBJECTSTACK_REF and no note saying why will conclude the pin was ' +
- 'dropped by accident and restore it — which is exactly the regression the test above ' +
- 'forbids. The absence has to be legible as a decision.',
- ).toBe(true);
- });
-});
-
-/**
- * objectui#8043 — the Half-State Patrol section told readers the sweeper's closed-card reader was
- * "switched **off** here via `PM_SWEEP_CLOSED_WINDOW_PAGES: '0'`". The workflow had stopped setting
- * that variable on 2026-08-28: the reader was ON with a dated floor, the page window deliberately
- * absent, and the retired knob survived only in the workflow's header comments as history. So the
- * page sent anyone looking for the switch to a variable nothing set, and — worse in the direction
- * this page is read — it described a predicate as disabled while it ran four times a day.
- *
- * The `workflow inventory` block above cannot see this: it matches filenames in headings and in
- * the inventory table's first column, so a
- * false sentence *inside* a documented section is exactly the drift it is blind to (objectui#7852
- * says so in as many words). This block closes that gap for the one thing on this page that names
- * the patrol's wiring by identifier.
- *
- * objectui#11174 MOVED that wiring, and this block moved with it. The workflow no longer sets the
- * sweeper's environment itself: it calls objectstack's composite action at a pinned sha and
- * configures it through that one step's `with:` INPUTS, which the action maps onto the sweeper
- * upstream. So the comparison is now the section against those inputs — the only place this
- * install records its wiring. The `PM_SWEEP_*` names the section still carries belong to its
- * hand-run recipe, which is the sweeper's own interface and not a setting of this workflow, and are
- * deliberately not read here.
- *
- * ⛔ The workflow side is the patrol step's `with:` mapping, PARSED, never the file as text: the
- * workflow's header argues about inputs (that is where the no-anchor opt-in is explained), and a
- * whole-file grep would accept a section naming any of them. `patrolStepOf` below is
- * unit-controlled against that shape.
- *
- * ⚠️ The page side is a heuristic, stated so it is not mistaken for a parser: the section's
- * backticked kebab-case tokens, which is how an action input is spelled. A future backticked
- * kebab-case word in this section that is NOT an input reads as a phantom below, and the failure
- * says so — rephrase it, or teach `inputsNamedIn` the difference.
- */
-const HALF_STATE_WORKFLOW = 'half-state-patrol.yml';
-
-/** The action every half-state install calls, up to the `@` that begins its ref. */
-const PATROL_ACTION = 'objectstack-ai/objectstack/.github/actions/half-state-patrol';
-
-type PatrolStep = { ref: string; inputs: Map };
-
-/**
- * The step of a workflow that calls the patrol action: its ref, and its `with:` inputs as the YAML
- * parser reads them (so `true` is a boolean and `''` an empty string, exactly as the runner gets
- * them before it stringifies). `undefined` when no step calls the action — which every assertion
- * below treats as a failure in its own right, never as "nothing to compare".
- */
-function patrolStepOf(yaml: string): PatrolStep | undefined {
- type Step = { uses?: unknown; with?: Record | null };
- type Job = { steps?: Step[] };
- const parsed = parseYaml(yaml) as { jobs?: Record } | null;
- for (const job of Object.values(parsed?.jobs ?? {})) {
- for (const step of job?.steps ?? []) {
- if (typeof step?.uses !== 'string' || !step.uses.startsWith(`${PATROL_ACTION}@`)) continue;
- return {
- ref: step.uses.slice(PATROL_ACTION.length + 1),
- inputs: new Map(Object.entries(step.with ?? {})),
- };
- }
- }
- return undefined;
-}
-
-/** The page section headed by ` (``)`, up to the next heading at that level or above. */
-function sectionForWorkflow(file: string): string {
- const lines = doc.split('\n');
- const start = lines.findIndex((line) => /^#{1,6}\s/.test(line) && line.includes(file));
- if (start === -1) return '';
- const level = lines[start].match(/^#+/)![0].length;
- const after = lines.slice(start + 1);
- const next = after.findIndex((line) => new RegExp(`^#{1,${level}}\\s`).test(line));
- return (next === -1 ? after : after.slice(0, next)).join('\n');
-}
-
-/**
- * The action inputs a piece of prose names: backticked kebab-case tokens, alone or as the key of a
- * backticked `name: value`. Dotted file names, slash paths and `word:word` labels are not this
- * shape, so they drop out on their own.
- */
-function inputsNamedIn(prose: string): string[] {
- const names = new Set();
- for (const m of prose.matchAll(/`([a-z][a-z0-9]*(?:-[a-z0-9]+)+)(?::\s[^`]*)?`/g)) names.add(m[1]);
- return [...names].sort();
-}
-
-describe('ci-cd-pipeline.md — Half-State Patrol wiring (#8043, objectui#11174)', () => {
- const section = sectionForWorkflow(HALF_STATE_WORKFLOW);
- const step = patrolStepOf(readWorkflow(HALF_STATE_WORKFLOW));
- const passed = [...(step?.inputs.keys() ?? [])].sort();
- const named = inputsNamedIn(section);
-
- it('has both sides to compare — neither may be empty', () => {
- // The vacuity legs. Each has a failure mode that renders the comparisons below green while
- // checking nothing, and each fails silently: a renamed heading empties the section, a step
- // that stopped calling the action (or moved its inputs) empties the workflow side, and a
- // rewrite that drops every input name leaves the page describing the wiring without naming it.
- expect(
- section,
- `No heading on the page names \`${HALF_STATE_WORKFLOW}\`, so this block has no section to ` +
- 'read and its comparisons below would pass vacuously. The `workflow inventory` block ' +
- 'requires that heading to exist; if it moved, teach `sectionForWorkflow` where it went.',
- ).not.toBe('');
-
- expect(
- step,
- `${HALF_STATE_WORKFLOW} has no step whose \`uses:\` calls ${PATROL_ACTION}. Either the ` +
- 'patrol stopped calling the action — and the section describes a configuration that is ' +
- 'gone — or the reference changed shape and `patrolStepOf` is reading the wrong thing.',
- ).toBeDefined();
-
- expect(
- passed,
- `The patrol step in ${HALF_STATE_WORKFLOW} passes no \`with:\` input at all, so the ` +
- "comparisons below have nothing on the workflow side. The action's `github-token` is " +
- 'required, so a step with no inputs cannot run — this is the reader failing, not the ' +
- 'configuration emptying.',
- ).not.toEqual([]);
-
- expect(
- named,
- 'The Half-State Patrol section names no action input at all. The reader needs at least ' +
- "the closure floor and the anchor opt-in: they are the things about this install that " +
- "are not the action's own defaults (objectui#8043, objectui#8740).",
- ).not.toEqual([]);
- });
-
- it("reads the patrol step's parsed inputs, not the file as text", () => {
- // The control for the docblock above. A commented-out input is HISTORY, a folded scalar's body
- // is a VALUE, and another step's `with:` is another action's — a text scan cannot tell any of
- // the three from the patrol's own wiring.
- const specimen = [
- 'jobs:',
- ' patrol:',
- ' steps:',
- ' - uses: actions/setup-node@v7',
- ' with:',
- " node-version: '22'",
- ` - uses: ${PATROL_ACTION}@${'a'.repeat(40)}`,
- ' with:',
- " # retired-input: '0' — read until the cutover; history, not a setting",
- " closed-floor: '2026-08-28'",
- ' folded: >-',
- ' not-a-key: still just prose',
- '',
- ].join('\n');
-
- const read = patrolStepOf(specimen);
- expect(read?.ref).toBe('a'.repeat(40));
- expect([...(read?.inputs.keys() ?? [])].sort()).toEqual(['closed-floor', 'folded']);
- expect(patrolStepOf(specimen.replace(PATROL_ACTION, 'someone/else/.github/actions/x'))).toBeUndefined();
- // And the page-side reader: an input, an input with its value, and the three shapes that are
- // not inputs and must drop out.
- expect(
- inputsNamedIn("`closed-floor` · `anchor-optional: true` · `x.yml` · `a/b-c` · `pm:dispatched`"),
- ).toEqual(['anchor-optional', 'closed-floor']);
- });
-
- it('names only inputs the workflow actually passes', () => {
- const phantom = named.filter((name) => !passed.includes(name));
-
- expect(
- phantom,
- 'The Half-State Patrol section names these action inputs:\n' +
- phantom.map((n) => ` - ${n}`).join('\n') +
- `\n\n…and the patrol step in \`${HALF_STATE_WORKFLOW}\` passes none of them. What it does ` +
- 'pass is:\n' +
- passed.map((n) => ` - ${n}`).join('\n') +
- '\n\nA reader who goes looking for the knob the page names finds an input nothing sets, ' +
- 'and — the expensive direction — believes whatever the page says that knob is doing. ' +
- "That is objectui#8043's shape, one wiring later. Fix the page against the workflow: the " +
- "patrol step's `with:` block is where this install records its wiring. (If the token is " +
- 'a backticked kebab-case word that is not an input at all, see this block\'s docblock.)',
- ).toEqual([]);
- });
-
- it('names every input the workflow passes', () => {
- const missing = passed.filter((name) => !named.includes(name));
-
- expect(
- missing,
- `The patrol step in \`${HALF_STATE_WORKFLOW}\` passes these inputs and the Half-State ` +
- 'Patrol section never names them:\n' +
- missing.map((n) => ` - ${n}`).join('\n') +
- "\n\nThe section presents the step's inputs as the whole of this install's configuration, " +
- 'so an input it omits is a setting a reader cannot learn from this page exists.',
- ).toEqual([]);
- });
-
- it('quotes the closure floor the step is actually given', () => {
- const floor = step?.inputs.get('closed-floor');
-
- expect(
- typeof floor === 'string' && floor !== '' ? floor : undefined,
- '`closed-floor` is no longer passed to the patrol action as a non-empty value in ' +
- `${HALF_STATE_WORKFLOW}. If the floor was removed, the closed-card reader now judges the ` +
- 'whole window and the section is wrong in the other direction; if it merely moved to an ' +
- 'expression, this assertion needs to read it from wherever the value now lives.',
- ).toBeDefined();
-
- expect(named, 'the section must keep naming the floor input').toContain('closed-floor');
-
- expect(
- section,
- `The workflow floors the closed-card reader at ${String(floor)}, and the Half-State Patrol ` +
- 'section does not say so. The date is the whole of the decision — it is what separates ' +
- '"the reader is off" from "the reader judges everything closed since the convention ' +
- 'started" — so a page that names the input without its value tells a reader nothing they ' +
- 'can check.',
- ).toContain(String(floor));
- });
-
- it('describes the anchor configuration the step actually passes (objectui#8740)', () => {
- // The section says this board has NO anchor issue and that this is declared, not omitted.
- // Both halves of that declaration are read off the step: an anchor number passed here, or the
- // opt-in dropped, makes the section's account of where the findings go false.
- const anchor = step?.inputs.get('anchor-issue');
- const optional = step?.inputs.get('anchor-optional');
-
- expect(
- { anchor, optional: String(optional) },
- `The patrol step in ${HALF_STATE_WORKFLOW} no longer passes an empty \`anchor-issue\` ` +
- 'beside `anchor-optional: true`. The Half-State Patrol section tells readers this board ' +
- 'has no anchor issue by decision and that its findings live in run summaries ' +
- '(objectui#8740, objectstack-ai/objectstack#20793 decision A). If an anchor was ' +
- 'configured — which reverses the maintainer\'s decline on objectui#7852 — rewrite that ' +
- 'part of the section with it; if the opt-in was dropped, every scheduled run is now red.',
- ).toEqual({ anchor: '', optional: 'true' });
-
- expect(section).toContain('`anchor-optional: true`');
- expect(named).toContain('anchor-issue');
- });
-
- it('pins the action to a 40-character sha, as the section says — never a branch', () => {
- // The half of the pin ruling no other check holds. The Action Ref Convention gate's
- // exception for this reference is matched by workflow and action, not by spelling, so
- // `@main` in place of the sha would pass that gate untouched.
- expect(
- step?.ref,
- `${HALF_STATE_WORKFLOW} calls ${PATROL_ACTION} at \`@${step?.ref}\`, which is not a ` +
- "40-character commit sha. A sibling board pins the action to a sha and never to `@main` " +
- '(objectstack-ai/objectstack#18471): `@main` adopts every upstream change on the next ' +
- 'run with no reviewed moment and nothing to roll back to. To bump the pin, take ' +
- "objectstack `main`'s tip as read at that moment and record it in the pull request.",
- ).toMatch(/^[0-9a-f]{40}$/);
-
- expect(section).toContain('40-character objectstack commit sha');
- expect(section).toContain('never `@main`');
- });
-});
-
-/**
- * objectui#8238 — the lane promised an artefact its configuration cannot produce.
- *
- * `live-e2e.yml`'s header, its job-summary step, its upload glob and this page's Live E2E
- * section were FOUR spellings of one claim — "failures surface as an uploaded Playwright
- * report" — and all four were decided by a single line somewhere else entirely:
- * `playwright.live.config.ts`'s `reporter`. That line reads `[['list']]`; the `list` reporter
- * writes to stdout and nothing in that config writes `playwright-report/`, so the promised
- * report was unreachable in EVERY outcome. Measured on a passing run and on a failing run:
- * absent both times, with an `html` reporter as the control that proves the directory is
- * observable when a reporter actually writes it.
- *
- * Nothing read those four sites against the config, which is exactly how they drifted, and
- * the cost was not cosmetic: objectui#8084's acceptance criterion was written as "a
- * `playwright-report/` appears in the uploaded artifact", so a correct fix could never have
- * satisfied its stated test. An acceptance criterion nobody can pass is the same hazard as a
- * check nobody can fail, pointed the other way.
- *
- * So this block pins the claim to the mechanism rather than to a sentence. It does not care
- * which way the repository decides the question — it requires only that the reporter list and
- * everything that describes it move together. Turn the HTML reporter on and this test goes red
- * naming every site that must be updated with it; leave it off and the sites must keep quoting
- * the reporter value that makes the absence true.
- */
-const LIVE_CONFIG_FILE = 'playwright.live.config.ts';
-const liveConfig = fs.readFileSync(path.join(repoRoot, LIVE_CONFIG_FILE), 'utf8');
-
-/**
- * The `reporter:` value exactly as written in a Playwright config, e.g. `[['list']]`.
- *
- * Comments come off first, and they come off through `scripts/js-comment-mask.mjs` — this
- * tree's one answer to "is this span a comment, or code?". The YAML-oriented
- * `withoutComments` above cannot be reused: this is TypeScript, and
- * `playwright.live.config.ts` opens with a 20-line block comment that names the config's
- * behaviour in prose. A whole-file regex would read that prose as configuration — the same
- * class of mistake as counting a commented-out `env:` key.
- *
- * ⛔ And a whole-file regex is exactly what this reader used to do about it (objectui#9613).
- * The paragraph above argued the case against the naive family and the function underneath it
- * WAS one: a block-comment regex plus a `//`-to-end-of-line rule, the second carrying an
- * ad-hoc one-character guard so that the `:` in a URL would save it. A regex has no idea what
- * a string literal is; the guard is a patch over that, not an answer to it.
- *
- * ## How it survived the conversion that was supposed to catch it
- *
- * objectui#9183 moved 29 source-scanning carriers onto the shared mask and ruled this file
- * out of scope because it strips Markdown and YAML — true of the file, and false of this
- * site inside it. ⭐ A census keyed on which FILE holds a stripper cannot see a second
- * stripper in a MIXED file, and an explicit pass and an unseen site read identically
- * afterwards. That is the reusable half of the card, not the call site.
- *
- * ## What was measured before the swap, so the claim is re-derivable
- *
- * Over `playwright.live.config.ts` as it stands, the two strippers agree BYTE FOR BYTE:
- * 1,779 comment bytes either way, zero characters that one calls prose and the other calls
- * code in either direction, and the same `[['list']]` out of both. The population checked was
- * the whole file — every line holding a `//` (four: three `http://` URLs behind a `:`, and
- * the real line comment beside `SELF_DEPTH_BELOW_REPO_ROOT`, which is the only one the naive
- * rule fires on), every string literal holding a `/` (seven), every string literal holding a
- * block-comment opener or terminator (none) and every regex literal (none). So the defect was
- * LATENT: nothing in the config today is mis-read, and the value this reader reports was
- * never wrong. What the swap buys is the shape the config does not hold today and a
- * Playwright config plausibly grows tomorrow — pinned below rather than described here.
- *
- * `stripComments` and not `maskComments` because this reader reports neither a line number
- * nor a byte offset into the source — it returns a captured value — which is the module's own
- * stated rule for choosing between its two projections.
- */
-function reporterValueOf(source: string): string | undefined {
- return stripComments(source).match(/^\s*reporter:\s*(.+?),?\s*$/m)?.[1];
-}
-
-describe('ci-cd-pipeline.md + live-e2e.yml — the Playwright report claim (#8238)', () => {
- const section = sectionForWorkflow('live-e2e.yml');
- const liveWorkflow = readWorkflow('live-e2e.yml');
- const header = liveWorkflow.slice(0, liveWorkflow.indexOf('\nname:'));
- const reporter = reporterValueOf(liveConfig);
- const declaresHtmlReporter = /['"]html['"]/.test(reporter ?? '');
-
- it('has all four sides to compare — none may be empty', () => {
- // The vacuity legs. Each failure below is silent: a renamed heading empties the section, a
- // restructured config empties the reporter read, and a header that no longer leads the file
- // empties the header slice. Any one of them would make the assertions pass while comparing
- // nothing — which is the failure mode this whole block exists to prevent elsewhere.
- expect(
- section,
- 'No heading on this page names `live-e2e.yml`, so this block has no section to read and ' +
- 'its assertions below would pass vacuously. The `workflow inventory` block requires ' +
- 'that heading to exist; if it moved, teach `sectionForWorkflow` where it went.',
- ).not.toBe('');
-
- expect(
- reporter,
- `${LIVE_CONFIG_FILE} no longer declares a \`reporter:\` on a line of its own. That line is ` +
- 'what decides whether `playwright-report/` can exist, and every sentence pinned below ' +
- 'describes it. If the reporter moved to a variable or a spread, this assertion has to ' +
- 'read it from wherever the value now lives — do not delete the comparison, or the four ' +
- 'claim sites go back to being unchecked prose (objectui#8238).',
- ).toBeDefined();
-
- expect(header, 'live-e2e.yml must still open with its header comment block').not.toBe('');
- });
-
- it('reads the config, not the file as text', () => {
- // The control for the paragraph above. `playwright.live.config.ts`'s header describes the
- // config in prose; a whole-file grep cannot tell that prose from a setting. This is the
- // exact shape of that file, and it is why the parser strips comments first.
- const specimen = [
- '/**',
- " * Run with reporter: [['html']] when you want a browsable report.",
- ' */',
- 'export default defineConfig({',
- " // reporter: [['html']], — history, not a setting",
- " reporter: [['list']],",
- '});',
- ].join('\n');
-
- expect(reporterValueOf(specimen)).toBe("[['list']]");
- });
-
- it('survives a glob, which is where the hand-rolled stripper would have broken', () => {
- // objectui#9613's carrier, and the reason the reader above goes through the shared mask.
- // The specimen in the previous case is the shape the config has TODAY, and the two-regex
- // stripper this reader used to carry got that one right — measured over the real
- // `playwright.live.config.ts`, the two agreed byte for byte. This is the shape the config
- // does NOT have: a Playwright glob, which spells a block-comment opener inside a string.
- //
- // A regex cannot see a string literal, so it opens a comment there and runs to the next
- // real terminator — the docblock BELOW `reporter:` — taking the reporter line with it.
- // The reader then answers `undefined` and the vacuity leg above reports that the config
- // no longer declares a reporter at all: a false alarm pointing at a config that is fine,
- // which is the expensive direction to fail in. Measured on this exact specimen: the old
- // stripper yields `undefined`, the shared mask yields `[['list']]`.
- //
- // ⛔ Not a synthetic stand-in for the file's behaviour — `testIgnore` and `testMatch` take
- // globs, and this config is one `testIgnore:` away from the shape. The claim about the
- // real file is the measurement in `reporterValueOf`'s docblock, not this case.
- const withGlob = [
- '/**',
- " * Run with reporter: [['html']] when you want a browsable report.",
- ' */',
- 'export default defineConfig({',
- " testIgnore: '**/*.wip.spec.ts',",
- " // reporter: [['html']], — history, not a setting",
- " reporter: [['list']],",
- ' /** Serial by design, so a retry is cheap. */',
- " use: { trace: 'on-first-retry' },",
- '});',
- ].join('\n');
-
- expect(reporterValueOf(withGlob)).toBe("[['list']]");
- });
-
- it('uploads no path the reporter cannot produce', () => {
- // Mechanical and exact: the glob may name `playwright-report/` only when a reporter that
- // writes it is declared. This is the half of #8238 that is not a matter of wording — the
- // upload step listed a directory that could not exist in any outcome, and `upload-artifact`
- // reports that as a warning, not a failure, so nothing ever went red over it.
- const globsReport = /^\s*playwright-report\/\s*$/m.test(withoutComments(liveWorkflow));
-
- expect(
- globsReport,
- declaresHtmlReporter
- ? `${LIVE_CONFIG_FILE} declares an HTML reporter (\`${reporter}\`) but live-e2e.yml no ` +
- 'longer uploads `playwright-report/`, so the lane now produces a report and throws it ' +
- 'away. Add the path back to the upload glob.'
- : `live-e2e.yml's upload glob names \`playwright-report/\`, but ${LIVE_CONFIG_FILE} ` +
- `declares \`reporter: ${reporter}\` — no reporter in it writes that directory, so the ` +
- 'path matches nothing on a pass, a fail or a crash. Either declare an HTML reporter in ' +
- 'that config, or drop the path. ⛔ Do not do neither: the glob is read by humans as a ' +
- 'promise that the artefact contains a report, and objectui#8084 wrote an acceptance ' +
- 'criterion on exactly that reading which no fix could ever have satisfied.',
- ).toBe(declaresHtmlReporter);
- });
-
- it('quotes the reporter value both prose sites are describing', () => {
- // The fence, adopted verbatim from #8238: the header, this page's section and the upload
- // glob are spellings of ONE claim and must move together. Requiring both prose sites to
- // quote the reporter value as written turns "they must move together" into something a test
- // can hold: flip the config and both sentences go red until someone rewrites them.
- for (const [name, text] of [
- ['live-e2e.yml’s header comment', header],
- ['the Live E2E section of content/docs/guide/ci-cd-pipeline.md', section],
- ] as const) {
- expect(
- text,
- `${name} does not quote \`${reporter}\`, the reporter list ${LIVE_CONFIG_FILE} actually ` +
- 'declares. Both sites tell the reader what a failing run leaves behind, and that ' +
- 'answer is decided entirely by this value — a site that describes the artefacts ' +
- 'without naming the line that produces them is how this lane spent its whole ' +
- 'existence promising a Playwright report it could not write (objectui#8238).',
- ).toContain(reporter!);
- }
- });
-
- it('says that a green run uploads nothing at all', () => {
- // #8238's second and sharper edge, and the one that outlives whichever way the reporter
- // question is decided. The upload step is gated on `failure()`, so on a green lane it never
- // runs: there is no artifact, so there is no file count, so any acceptance criterion
- // phrased over the artifact's contents is readable ONLY on a run that failed. That trap is
- // what produced objectui#8084's unpassable criterion, and it is invisible from the prose
- // unless the prose says it.
- const uploadStep = withoutComments(liveWorkflow).slice(
- withoutComments(liveWorkflow).indexOf('name: Upload'),
- );
-
- expect(
- uploadStep,
- 'live-e2e.yml has no `Upload` step, so the sentence pinned below is describing a step ' +
- 'that is gone. Re-point this assertion or drop the sentence with it.',
- ).not.toBe('');
-
- expect(
- /if:.*failure\(\)/.test(uploadStep.split('\n').slice(0, 6).join('\n')),
- 'live-e2e.yml’s upload step is no longer gated on `failure()`. If it now runs on green ' +
- 'runs too, the warning on the page — that a green lane leaves no artifact to inspect — ' +
- 'has become false and must be removed in the same PR.',
- ).toBe(true);
-
- expect(
- section,
- 'The Live E2E section does not mention `failure()`. The upload step is gated on it, so a ' +
- 'green run of this lane produces NO artifact — not an empty one, none — and a reader ' +
- 'who does not know that will write a check against artefact contents that can only ever ' +
- 'be read on a red run. objectui#8084 did exactly that. Say it on the page.',
- ).toContain('failure()');
- });
-});
-
-/**
- * objectui#8084 defect ② — the lane's RUN conclusion asserted the negation of its JOB's.
- *
- * Measured on the 2026-09-06 nightly, while `live-e2e.yml`'s job carried
- * `continue-on-error: true`:
- *
- * step 7 `Start ObjectStack backend` failure
- * job 101442890465 failure
- * check run `Live E2E (informational)` failure
- * check suite 92170955546 success
- * workflow run 34017174769 success
- *
- * The flag moved neither the job nor its check run — the two readings a merge gate and a
- * reviewer consult — and inverted only the aggregate. This is worse than an omission: any
- * monitor, digest or agent reading run-level conclusions was told `success` while the live
- * E2E had not executed at all, which is why the backend outage carded as that issue's defect
- * ① ran a full day unseen. An outage detector that reports the negation of what it detects.
- *
- * ⛔ The repair is NOT to make the lane blocking, and this pin does not ask for that: what
- * makes the lane non-blocking is that it is not in the required-check set and declares no
- * `merge_group` trigger, neither of which `continue-on-error` had anything to do with. The
- * two are compatible; conflating them is the trap the card names.
- *
- * ## Why this is scoped to the job's OWN keys, and why the control below is not optional
- *
- * The entry this replaces in STRUCTURAL_BLOCKS asked `/^\s*continue-on-error:\s*true\s*$/m`
- * of the whole job block and promised, in writing, to go red the day the flag came off the
- * job. It could not: objectui#7048 later split both caches into bounded `save` STEPS that
- * each carry `continue-on-error: true` at step level, and `\s*` matches their indentation
- * just as happily. Two step-level flags kept that assertion green for a property that no
- * longer existed — an assertion that cannot fail, which is this card's own defect one level
- * down.
- *
- * So the matcher here is anchored to four spaces, the indentation of a job's own keys, and
- * the discrimination is EXERCISED rather than assumed: the control asserts that step-level
- * flags really are present in this job and really are not matched. If those saves are ever
- * removed, the control goes red and says so, rather than leaving a matcher that discriminates
- * against nothing.
- */
-describe('live-e2e.yml — the run conclusion may not contradict the job (#8084)', () => {
- const liveJob = nestedBlock(topLevelBlock(withoutComments(readWorkflow('live-e2e.yml')), 'jobs'), 'live-e2e');
- const jobOwnKeys = liveJob.split('\n').filter((line) => /^ {4}[a-z]/i.test(line));
- const section = sectionForWorkflow('live-e2e.yml');
-
- it('has a job block and a page section to read — neither may be empty', () => {
- // The vacuity legs. A renamed job or heading would make every assertion below pass while
- // reading an empty string, which is the failure shape this whole file exists against.
- expect(
- liveJob,
- 'the `live-e2e` job is gone from live-e2e.yml (or `jobs:` no longer parses), so the ' +
- 'assertions below would compare nothing. Re-point them at wherever the lane now lives.',
- ).not.toBe('');
-
- expect(
- jobOwnKeys.map((line) => line.trim().split(':')[0]),
- "the job-level key scan found no four-space keys at all, so 'no continue-on-error here' " +
- 'would be true of every possible file. Re-derive it from the current indentation.',
- ).toContain('timeout-minutes');
-
- expect(
- section,
- 'No heading on this page names `live-e2e.yml`, so the page half of this claim reads an ' +
- 'empty string and passes vacuously.',
- ).not.toBe('');
- });
-
- it('discriminates job level from step level — the control, exercised not assumed', () => {
- // Without this, the assertion below is indistinguishable from one that would also pass on
- // the broken file: the two cache saves are the exact lines that kept the old pin green.
- const anywhereInJob = liveJob.split('\n').filter((line) => /^\s*continue-on-error:\s*true\s*$/.test(line));
-
- expect(
- anywhereInJob.length,
- 'live-e2e.yml no longer carries any step-level `continue-on-error: true` (the two bounded ' +
- 'cache saves objectui#7048 added). Those lines are what made a whole-job regex unable ' +
- 'to notice the job-level flag leaving. With them gone the control below no longer ' +
- 'proves the matcher discriminates — either restore a control, or state here that the ' +
- 'distinction has stopped mattering.',
- ).toBeGreaterThan(0);
-
- expect(
- anywhereInJob.filter((line) => /^ {4}\S/.test(line)),
- 'a four-space `continue-on-error: true` slipped through the step-level filter, which ' +
- 'means the two matchers no longer disagree and the control is measuring nothing.',
- ).toEqual([]);
- });
-
- it('declares no `continue-on-error` on the job itself', () => {
- const jobLevel = jobOwnKeys.filter((line) => /^ {4}continue-on-error\s*:/.test(line));
-
- expect(
- jobLevel,
- 'The `live-e2e` job has `continue-on-error` back on it:\n' +
- jobLevel.map((l) => ` ${l.trim()}`).join('\n') +
- '\n\nThat flag does not make this lane advisory — it is advisory because it is not a ' +
- 'required check and declares no `merge_group` trigger. What it does is make the ' +
- 'workflow-run and check-suite conclusions report `success` while the job and its check ' +
- 'run report `failure`, so every run-level reader is told the negation of what happened ' +
- '(objectui#8084, measured on run 34017174769). If the lane must stop failing runs, fix ' +
- 'the lane or narrow what it asserts; ⛔ do not restore the flag that made the aggregate ' +
- 'disagree with its own job. Step-level `continue-on-error:` on the cache saves is a ' +
- 'different thing and stays.',
- ).toEqual([]);
- });
-
- it('says on the page that the two conclusions agree', () => {
- // The map -> page direction, so the sentence cannot be dropped while this stays green.
- for (const phrase of ['continue-on-error', 'run conclusion']) {
- expect(
- section,
- `The Live E2E section of content/docs/guide/ci-cd-pipeline.md no longer mentions ` +
- `"${phrase}". That section is where a reader learns this lane is advisory AND honest ` +
- `— that its run conclusion agrees with its job — and unpinned prose about this exact ` +
- `property is what objectui#8084 was filed about. Say it there, or retire this pin ` +
- `with it.`,
- ).toContain(phrase);
- }
- });
-});
-
-/**
- * ── The alias rule, and the section reader both parity describes share ───────
- *
- * Settled by objectui#8420's first instance pair and documented in full in the
- * header of the `vi-mock and shadcn` describe below — that comment is still where
- * the DECISION lives and where the limits on it are argued. What moved here is
- * only the CODE, when objectui#8420's second instance set needed the same rule
- * for four more sections: two copies of a rule are two rules, and the next edit
- * would have fixed one of them. Module scope so there is exactly one.
- *
- * ⛔ Moving it changes nothing about its blast radius. `ci.yml`'s job table and
- * the `lint.yml` section still call `commandParity` directly and are still pinned
- * by the unmodified module-scope rule; only a describe that calls `byGate` opts in.
- */
-
-/** Root `package.json` script bodies, for resolving an alias to the gate it runs. */
-const rootScriptBodies = (
- JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as {
- scripts: Record;
- }
-).scripts;
-
-/** `scripts/` -> the one root alias that is exactly `node scripts/`. */
-function aliasByScript(): Map {
- const map = new Map();
- for (const [name, body] of Object.entries(rootScriptBodies)) {
- const wrapped = /^node\s+(scripts\/[\w./-]+)$/.exec(body.trim())?.[1];
- if (!wrapped) continue;
- map.set(wrapped, [...(map.get(wrapped) ?? []), name]);
- }
- return map;
-}
-
-/**
- * One spelling per gate: the alias when the alias is a pure wrapper, otherwise the
- * command as written. Applied to both sides of a unit, so the two are compared by
- * one rule rather than by two spellings of it.
- */
-function canonical(command: string, aliases: Map): string {
- const named = aliases.get(command);
- if (named?.length === 1) return `pnpm ${named[0]}`;
- return command;
-}
-
-/** A unit with both of its sides put through `canonical`. */
-function byGate(unit: CommandParity): CommandParity {
- const aliases = aliasByScript();
- return {
- label: unit.label,
- ran: new Set([...unit.ran].map((c) => canonical(c, aliases))),
- named: new Set([...unit.named].map((c) => canonical(c, aliases))),
- };
-}
-
-/** A `##`/`###` section of the page, up to the next heading at its level or above. */
-function section(heading: string): string {
- const start = doc.indexOf(heading);
- expect(start, `the page must still have a "${heading}" section`).toBeGreaterThan(-1);
- const level = /^#+/.exec(heading)![0].length;
- const rest = doc.slice(start + heading.length);
- const next = rest.search(new RegExp(`^#{1,${level}} `, 'm'));
- return next === -1 ? rest : rest.slice(0, next);
-}
-
-/**
- * objectui#8420: the same pairing again, on the two sections the card measured by
- * hand — and the alias question that had to be settled before either pin could be
- * pointed at them.
- *
- * ## The two instances
- *
- * `vi-mock-specifiers.yml` runs TWO gates in one job: `check-vi-mock-specifiers.mjs`
- * and `check-vi-mock-inherit.mjs`. The section named only the first, and the second
- * is a blocking step of a required context (`dependabot-merge-gate.mjs` classifies
- * `Inert vi.mock Specifier Check`) — so a contributor reading that section to learn
- * what CI does to their pull request did not know a gate that can stop it exists.
- * Measured on `256c709e2`, searching the whole page for both spellings in one
- * command: `check-vi-mock-inherit` returned 0 and `check-vi-mock-specifiers`
- * returned 3, which is what makes the zero a reading rather than a failed probe.
- *
- * `shadcn-check.yml`'s section described the job in three prose bullets and named
- * none of the three first-party commands it runs.
- *
- * ## ⛔ Why this is two pins and not a loop over the page
- *
- * The card measured the naive extension — pair every `## Heading (some.yml)`
- * section against every job in that workflow — and it flags 24 of the 34 sections,
- * MOST of which are not defects: a section that names a neighbouring gate for
- * contrast (and says so), an illustrative `scripts/some-gate.mjs` placeholder
- * inside a code block, and sections that name a `pnpm check:*` alias where the
- * workflow invokes `node scripts/…` directly. A gate that cries wolf gets switched
- * off rather than fixed — this repository's own words, in
- * `check-unreferenced-sources`. So sections join this rule one at a time, each
- * after somebody has decided what that section's documentation surface IS.
- *
- * ## ⭐ The alias decision, and its blast radius
- *
- * Settled here for these two units only: **an alias whose root `package.json`
- * definition is exactly `node scripts/` names the same gate as invoking that
- * file directly.** `pnpm check:vi-mock-specifiers` IS
- * `node scripts/check-vi-mock-specifiers.mjs` — same script, same population, same
- * verdict — and the vi.mock section names the alias because that is how a
- * contributor runs it locally. Without this, `undocumentedCommands` reads the two
- * spellings as two commands and reports the page's own "run it locally" line as a
- * phantom gate: cry wolf, on the first section this rule was pointed at.
- *
- * What the decision deliberately does NOT do:
- *
- * - **It does not merge an alias that carries arguments.** `pnpm shadcn:check` is
- * `node scripts/shadcn-sync.js --check` and `pnpm shadcn:update` is the same
- * script with `--update`; collapsing both onto the script would let the page
- * document one and the workflow run the other. An alias with arguments selects
- * a MODE, and is only ever the same command as itself.
- * - **It does not reach the other 32 sections, nor the `ci.yml` table or the
- * `lint.yml` section above.** Those two were settled without it and are pinned
- * by the unmodified module-scope rule; re-pointing them is a separate card, not
- * a silent side effect of this one.
- * - **It does not make the pre-install distinction disappear.** Several workflows
- * invoke `node scripts/…` rather than the alias because the step runs BEFORE
- * `pnpm install`, which is real and deliberate. That is a fact about step
- * PLACEMENT; this rule measures which gates a job runs, and on that question
- * the two spellings are one gate.
- */
-describe('ci-cd-pipeline.md — the vi-mock and shadcn sections', () => {
- const VI_MOCK_HEADING = '### Inert vi.mock Specifiers — the Inert vi.mock Specifier Check step';
- const SHADCN_HEADING = '### Shadcn Component Check (`shadcn-check.yml`)';
-
- function units(): CommandParity[] {
- return [
- byGate(
- checkParity(
- 'Inert vi.mock Specifier Check',
- section(VI_MOCK_HEADING),
- 'repo-gates.yml `Inert vi.mock Specifier Check`',
- ),
- ),
- byGate(
- commandParity(
- 'shadcn-check.yml',
- 'check-components',
- section(SHADCN_HEADING),
- 'shadcn-check.yml `check-components`',
- ),
- ),
- ];
- }
-
- it('resolves each pure alias to exactly one gate', () => {
- // The control on the decision above. Two root scripts spelled `node scripts/x`
- // for the same `x` would make `canonical` pick one arbitrarily, and the pins
- // below would then compare a spelling nobody chose. `canonical` keeps the raw
- // command when that happens; this says so out loud instead of leaving it silent.
- const ambiguous = [...aliasByScript()].filter(([, names]) => names.length > 1);
- expect(
- ambiguous.map(([script, names]) => `${script}: ${names.join(', ')}`),
- 'more than one root `package.json` script is exactly `node