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