diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa9f1e84f..733297e08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,14 +9,46 @@ on: - '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] - paths-ignore: - - '**/*.md' - - 'content/**' - - 'docs/**' - - 'apps/site/**' - - '.changeset/**' + # ── 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] concurrency: group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -64,13 +96,72 @@ jobs: # `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; the `|| echo ""` + # in the `docs` job below is the fail-CLOSED spelling and is why that job's + # own gate is reported separately (see the PR). + - 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 + if: steps.relevant.outputs.should_run == 'true' run: corepack enable - 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' @@ -79,9 +170,11 @@ jobs: # 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 - name: Install dependencies + if: steps.relevant.outputs.should_run == 'true' run: pnpm install --frozen-lockfile # A local type/const declared under a `@objectstack/spec` export's NAME reads @@ -90,6 +183,7 @@ jobs: # 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 # A key a component asks `t()` for must exist in the `en` pack. @@ -100,6 +194,7 @@ jobs: # 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 @@ -112,6 +207,7 @@ jobs: # 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 # `scripts/` is not a workspace package, so `pnpm type-check` (i.e. @@ -130,9 +226,11 @@ jobs: # 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 - name: Turbo Cache + if: steps.relevant.outputs.should_run == 'true' uses: actions/cache@v6 with: path: .turbo/cache @@ -144,6 +242,7 @@ jobs: # `type-check` dependsOn `^build`, so this builds workspace dependencies # first: the checked packages resolve their deps through built `.d.ts`. - name: Run type-check + if: steps.relevant.outputs.should_run == 'true' run: pnpm type-check # The four repo-root `vitest.setup.*` files were in ZERO tsc programs @@ -170,6 +269,7 @@ jobs: # 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 # PRs run the suite split across 4 runners. The suite is dominated by fixed @@ -188,7 +288,14 @@ jobs: name: Test (shard ${{ matrix.shard }}/4) # Coverage instrumentation (v8 adds 40-100% overhead) is skipped on PRs; # the `test-coverage` job below keeps Codecov current on push. - if: github.event_name == 'pull_request' + # + # 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 `test-coverage` below is the push lane, unsharded + # so Codecov receives one complete report. PR and push behaviour is + # unchanged — only the previously impossible third case moves. + if: github.event_name != 'push' 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. @@ -205,20 +312,59 @@ jobs: 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" + else + echo 'should_run=false' >> "$GITHUB_OUTPUT" + echo 'Only ignored paths changed. Skipping the steps below; this check still reports.' + fi - name: Enable Corepack + if: steps.relevant.outputs.should_run == 'true' run: corepack enable - 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' - name: Install dependencies + if: steps.relevant.outputs.should_run == 'true' run: pnpm install --frozen-lockfile # Run the canonical root Vitest project once. Running `turbo run test` @@ -226,6 +372,7 @@ jobs: # intentionally inherit the root monorepo project list, so CI ends up # repeating large chunks of the suite and can starve slower plugin tests. - name: Run tests (shard ${{ matrix.shard }}/4) + if: steps.relevant.outputs.should_run == 'true' run: pnpm test --shard=${{ matrix.shard }}/4 # Push to main/develop: one unsharded run so Codecov still receives a single @@ -281,23 +428,63 @@ jobs: 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" + else + echo 'should_run=false' >> "$GITHUB_OUTPUT" + echo 'Only ignored paths changed. Skipping the steps below; this check still reports.' + fi - name: Enable Corepack + if: steps.relevant.outputs.should_run == 'true' run: corepack enable - 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' - 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 @@ -312,6 +499,7 @@ jobs: 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" @@ -320,10 +508,12 @@ jobs: echo "Console build artifact is ready" - name: Get Playwright version + if: steps.relevant.outputs.should_run == 'true' id: playwright-version run: echo "version=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_OUTPUT - name: Cache Playwright browsers + if: steps.relevant.outputs.should_run == 'true' uses: actions/cache@v6 id: playwright-cache with: @@ -331,19 +521,20 @@ jobs: key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} - name: Install Playwright browsers - if: steps.playwright-cache.outputs.cache-hit != 'true' + if: steps.relevant.outputs.should_run == 'true' && steps.playwright-cache.outputs.cache-hit != 'true' run: pnpm exec playwright install --with-deps chromium - name: Install Playwright system dependencies - if: steps.playwright-cache.outputs.cache-hit == 'true' + if: steps.relevant.outputs.should_run == 'true' && steps.playwright-cache.outputs.cache-hit == 'true' run: pnpm exec playwright install-deps chromium - name: Run E2E tests + if: steps.relevant.outputs.should_run == 'true' run: pnpm test:e2e --project=chromium - name: Upload Playwright report uses: actions/upload-artifact@v7 - if: ${{ !cancelled() && failure() }} + if: ${{ steps.relevant.outputs.should_run == 'true' && !cancelled() && failure() }} with: name: playwright-report path: playwright-report/ @@ -364,7 +555,11 @@ jobs: - name: Check for docs changes id: docs-changes run: | - if [ "${{ github.event_name }}" = "push" ]; then + # `!= pull_request` covers `push` and `merge_group` alike + # (objectui#3523). A queue build has no `github.event.pull_request`, + # so the else-branch below would diff an empty revision range and + # skip the site build on the last check before `main`. + if [ "${{ github.event_name }}" != "pull_request" ]; then echo "should_run=true" >> "$GITHUB_OUTPUT" else # Check if docs-related files changed in this PR diff --git a/.github/workflows/control-bytes.yml b/.github/workflows/control-bytes.yml index 3a1e0222e..7dad7d6a3 100644 --- a/.github/workflows/control-bytes.yml +++ b/.github/workflows/control-bytes.yml @@ -20,6 +20,16 @@ on: 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). This repository's queue is enforced by a + # ruleset but had zero `merge_group` subscribers, so its required-check set + # could only ever be empty. This gate is one of the two the audit found safe + # to require today — deliberately unfiltered, so it reports on every shape of + # pull request — and a required check that does not report on a queue build + # stalls the queue until the ruleset's 60-minute timeout fails it. `types:` is + # named although `checks_requested` is currently the only one GitHub defines. + merge_group: + types: [checks_requested] workflow_dispatch: concurrency: diff --git a/.github/workflows/docs-links.yml b/.github/workflows/docs-links.yml index b41d9b55d..40a8a5fad 100644 --- a/.github/workflows/docs-links.yml +++ b/.github/workflows/docs-links.yml @@ -27,6 +27,16 @@ on: 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). This repository's queue is enforced by a + # ruleset but had zero `merge_group` subscribers, so its required-check set + # could only ever be empty. This gate is one of the two the audit found safe + # to require today — deliberately unfiltered, so it reports on every shape of + # pull request — and a required check that does not report on a queue build + # stalls the queue until the ruleset's 60-minute timeout fails it. `types:` is + # named although `checks_requested` is currently the only one GitHub defines. + merge_group: + types: [checks_requested] workflow_dispatch: concurrency: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 095c62338..e182b4520 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -34,13 +34,45 @@ on: - '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] - paths-ignore: - - '**/*.md' - - 'content/**' - - 'docs/**' - - '.changeset/**' + # ── 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: @@ -60,14 +92,61 @@ jobs: 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 + if: steps.relevant.outputs.should_run == 'true' run: corepack enable - 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' @@ -77,9 +156,11 @@ jobs: # 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 - name: Turbo Cache + if: steps.relevant.outputs.should_run == 'true' uses: actions/cache@v6 with: path: .turbo/cache @@ -88,7 +169,9 @@ jobs: turbo-${{ runner.os }}- - name: Install dependencies + if: steps.relevant.outputs.should_run == 'true' run: pnpm install --frozen-lockfile - name: Run linter + if: steps.relevant.outputs.should_run == 'true' run: pnpm lint diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 665ec4afc..e1b37c9fe 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -23,11 +23,11 @@ one has its own section below. | Workflow file | Appears as | Runs on | Blocks a PR? | |---|---|---|---| -| `ci.yml` | CI | Push / PR to `main`, `develop` | **Yes** — every job but `test-coverage` (push only) runs on PRs | -| `lint.yml` | Lint | Push / PR to `main`, `develop`; manual | **Yes** — ESLint **errors** only | +| `ci.yml` | CI | Push / PR to `main`, `develop`; merge-queue builds | **Yes** — every job but `test-coverage` (push only) runs on PRs and on queue builds | +| `lint.yml` | Lint | Push / PR to `main`, `develop`; merge-queue builds; manual | **Yes** — ESLint **errors** only | | `changeset-guard.yml` | Changeset Bump Policy | PR / push touching `.changeset/**` | **Yes** | -| `control-bytes.yml` | Control Byte Scan | Push / PR to `main`, `develop` — **no path filter**; manual | **Yes** | -| `docs-links.yml` | Internal Docs Link Check | Push / PR to `main`, `develop` — **no path filter**; manual | **Yes** | +| `control-bytes.yml` | Control Byte Scan | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** | +| `docs-links.yml` | Internal Docs Link Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** | | `performance-budget.yml` | Bundle Analysis | Push / PR touching `packages/**`, `apps/console/**`, `pnpm-lock.yaml` | **Yes** — the console entry gzip budget | | `live-e2e.yml` | Live E2E (informational) | PR to `main`, `develop` (code paths); nightly cron `30 6 * * *`; manual | No — informational lane, `continue-on-error` | | `labeler.yml` | Auto Label PRs | PR `opened`, `synchronize`, `reopened` | No | @@ -43,18 +43,78 @@ one has its own section below. The path filters explain most "why did nothing run on my PR?" questions: - `ci.yml` and `lint.yml` both list `**/*.md`, `content/**`, `docs/**` and `.changeset/**` under - `paths-ignore` (`ci.yml` also ignores `apps/site/**`). A docs-only or changeset-only PR starts - neither of them. + `paths-ignore` (`ci.yml` also ignores `apps/site/**`) — but **only on their `push` trigger**. + Their `pull_request` trigger carries no filter at all since + [#3523](https://github.com/objectstack-ai/objectui/issues/3523): every pull request starts both workflows, and the same list decides *inside + each job* whether the expensive steps run. A docs-only PR therefore still installs nothing and + builds nothing, while **Lint**, **Type Check**, **Test (shard N/4)**, **Build & E2E** and + **Changeset Fixed Group Check** all appear in the checks list and all report. That difference is + the whole point: a check that is never *created* cannot be a required check — it leaves the PR + pending rather than failing it — so while the filter sat on the trigger, none of these could be + required at all. - `changeset-guard.yml` carries the inverse filter — it runs *only* when `.changeset/**` changes, which is precisely why it is a separate workflow instead of a job inside `ci.yml`. - `control-bytes.yml` and `docs-links.yml` carry **no** filter of any kind, which is equally deliberate: both guard markdown, and a gate that a markdown-only PR cannot start is no gate on the change most likely to trip it. Both cost a checkout plus one `node` call. +## Merge Queue + +`main` sits behind an **enforced merge queue**: a direct push is rejected with +405 `Changes must be made through the merge queue`. The queue takes each approved pull request, +rebuilds it on top of whatever `main` has become in the meantime, and merges it only if the +checks it requires are green **on that rebuilt commit**. Those runs are a distinct event, +`merge_group`, on a throwaway `gh-readonly-queue/**` branch — a workflow that does not subscribe +to that event simply does not run there. + +Four workflows subscribe: `ci.yml`, `lint.yml`, `control-bytes.yml` and `docs-links.yml`. None of +them did until [#3523](https://github.com/objectstack-ai/objectui/issues/3523), and the consequence was not subtle. A queue whose required set +is empty validates nothing: it rebuilds the PR, sees no failing required check because there are +no required checks, and merges. On 2026-08-07 three pull requests +([#3503](https://github.com/objectstack-ai/objectui/issues/3503), [#3510](https://github.com/objectstack-ai/objectui/issues/3510), [#3516](https://github.com/objectstack-ai/objectui/issues/3516)) merged with **Type Check** at +`conclusion=failure`, onto a `main` that [#3498](https://github.com/objectstack-ai/objectui/issues/3498) had left with a type error; +[#3505](https://github.com/objectstack-ai/objectui/issues/3505) hot-fixed the result. + +**The three steps have to happen in this order**, and reversing them deadlocks the repository: + +1. Subscribe the workflows to `merge_group`. Pure addition — nothing about pull requests changes. +2. Make the contexts report on *every* pull request, by moving path filtering out of + `on.pull_request.paths-ignore` and into the jobs. +3. Only then may a maintainer add context names to the branch-protection and merge-queue required + sets. This is a **repository-settings** change; nothing in this repository can do it, and + nothing here can read the current state of it either. + +Step 3 before step 1 is the deadlock: a required context that never reports does not fail a queue +build, it stalls it until the ruleset's 60-minute status-check timeout assumes failure — every +queued PR burns an hour and fails, with nothing red to point at. + +Two things follow for anyone editing this directory: + +- **A workflow producing a context that could ever be required must subscribe `merge_group`.** + `scripts/__tests__/merge-queue-reporting.test.ts` holds the list, along with the reason each + entry is on it, and fails when one drops the trigger. +- **Some contexts can never be required, structurally**, and no amount of triggering changes that: + **Changeset Bump Policy** (`changeset-guard.yml`, inverse path filter — absent unless the PR + touches `.changeset/**`), **Bundle Analysis** (`performance-budget.yml`, path filter), + **Live E2E (informational)** (`continue-on-error: true`, so it is green whatever happens — it + cannot serve as a guarantee of anything), and **Close issues referenced in other repositories** + (`cross-repo-issue-closer.yml`, which runs only *after* a merge). + ## Core CI Workflow (`ci.yml`) -**Triggers:** Push and PR to `main` and `develop`, unless the change touches only `**/*.md`, -`content/**`, `docs/**`, `apps/site/**` or `.changeset/**` (`paths-ignore`). +**Triggers:** **Every** PR to `main`/`develop` (no path filter), every merge-queue build +(`merge_group`), and pushes to `main`/`develop` unless the change touches only `**/*.md`, +`content/**`, `docs/**`, `apps/site/**` or `.changeset/**` (`paths-ignore`, kept on the push +trigger only — see [#3523](https://github.com/objectstack-ai/objectui/issues/3523) and the **Merge Queue** section below). + +The path list did not go away, it moved. `type-check`, `test` and `e2e` each open with a +`Decide whether this change needs a full run` step that diffs the PR against its merge base with +exactly that list excluded, and every following step carries +`if: steps.relevant.outputs.should_run == 'true'`. The job always runs and always reports; the +paths decide only whether it does any work. The `docs` job has worked this way since +[#3450](https://github.com/objectstack-ai/objectui/pull/3450) and is where the shape comes from. +The gate fails **open** — if the diff cannot be computed the job runs everything, rather than +reporting green having built nothing. Every job runs in parallel — there are no `needs:` edges between them. As with the workflow inventory above, this page states **no job count**: the table *is* the list, and @@ -73,11 +133,11 @@ it green — which is how two of `type-check`'s gates came to be missing from th | Job key | Appears as | What it runs | When | |---|---|---|---| | `changeset-check` | Changeset Fixed Group Check | `scripts/check-changeset-fixed.mjs` — every workspace package must be in the changeset `fixed` group or explicitly ignored. It checks group *membership*; it does **not** check whether the PR added a changeset. | Every run | -| `type-check` | Type Check | `scripts/check-type-check-coverage.mjs`, then `pnpm check:spec-symbols`, then `pnpm check:i18n-keys`, then `pnpm check:i18n-drift`, then `pnpm type-check:scripts`, then `pnpm type-check`, then `pnpm type-check:vitest-setup`. The coverage guard runs first because turbo silently skips packages that have no `type-check` script, so a package without one would otherwise read as passing (#2911). The two locale gates sit in the middle because both parse the sources with `typescript`: they need the install and nothing built. `pnpm check:i18n-keys` fails when a `t()` call site asks for a key the `en` pack does not define ([#3530](https://github.com/objectstack-ai/objectui/issues/3530)); `pnpm check:i18n-drift` fails when a change to an `en` string is not accompanied by the nine translation packs ([#3650](https://github.com/objectstack-ai/objectui/issues/3650)), and it is why this job's checkout sets `fetch-depth: 0` — it diffs against the merge base, which a depth-1 clone cannot resolve. `pnpm type-check:scripts` (`tsconfig.scripts.json`) covers `scripts/**/*.ts`, which `pnpm type-check` cannot reach at all — `scripts/` has no package.json, so turbo never walks it, and the coverage guard decides coverage per *package*. Until [#3494](https://github.com/objectstack-ai/objectui/issues/3494) that left the pin tests in `scripts/__tests__/` — including the one pinning this very page — compiled by nothing. `pnpm type-check:vitest-setup` (`tsconfig.vitest-setup.json`) closes the same gap for the four repo-root `vitest.setup.*` files, uncovered until [#3515](https://github.com/objectstack-ai/objectui/issues/3515); it runs *last*, after `pnpm type-check`, because `vitest.setup.dom.tsx` side-effect-imports four `@object-ui/*` packages and resolves them through the declarations that turbo's `^build` produces. | Every run | -| `test` | Test (shard N/4) | `pnpm test --shard=N/4` across a 4-runner matrix with `fail-fast: false`, so every shard reports its own failures. No coverage instrumentation — v8 adds 40–100% overhead. | **Pull requests only** | +| `type-check` | Type Check | `scripts/check-type-check-coverage.mjs`, then `pnpm check:spec-symbols`, then `pnpm check:i18n-keys`, then `pnpm check:i18n-drift`, then `pnpm type-check:scripts`, then `pnpm type-check`, then `pnpm type-check:vitest-setup`. The coverage guard runs first because turbo silently skips packages that have no `type-check` script, so a package without one would otherwise read as passing (#2911). The two locale gates sit in the middle because both parse the sources with `typescript`: they need the install and nothing built. `pnpm check:i18n-keys` fails when a `t()` call site asks for a key the `en` pack does not define ([#3530](https://github.com/objectstack-ai/objectui/issues/3530)); `pnpm check:i18n-drift` fails when a change to an `en` string is not accompanied by the nine translation packs ([#3650](https://github.com/objectstack-ai/objectui/issues/3650)), and it is why this job's checkout sets `fetch-depth: 0` — it diffs against the merge base, which a depth-1 clone cannot resolve. `pnpm type-check:scripts` (`tsconfig.scripts.json`) covers `scripts/**/*.ts`, which `pnpm type-check` cannot reach at all — `scripts/` has no package.json, so turbo never walks it, and the coverage guard decides coverage per *package*. Until [#3494](https://github.com/objectstack-ai/objectui/issues/3494) that left the pin tests in `scripts/__tests__/` — including the one pinning this very page — compiled by nothing. `pnpm type-check:vitest-setup` (`tsconfig.vitest-setup.json`) closes the same gap for the four repo-root `vitest.setup.*` files, uncovered until [#3515](https://github.com/objectstack-ai/objectui/issues/3515); it runs *last*, after `pnpm type-check`, because `vitest.setup.dom.tsx` side-effect-imports four `@object-ui/*` packages and resolves them through the declarations that turbo's `^build` produces. | Every run; on a PR the steps short-circuit when only ignored paths changed | +| `test` | Test (shard N/4) | `pnpm test --shard=N/4` across a 4-runner matrix with `fail-fast: false`, so every shard reports its own failures. No coverage instrumentation — v8 adds 40–100% overhead. | Pull requests and merge-queue builds (everything but `push`); steps short-circuit on a PR that changed only ignored paths | | `test-coverage` | Test (coverage) | One unsharded `pnpm test:coverage`, uploaded to Codecov. Nothing blocks on it, which is why it is not sharded. | **Push only** | -| `e2e` | Build & E2E | Builds the console with `vite build` (`VITE_BASE_PATH=/console/`), verifies the artifact, then `pnpm test:e2e --project=chromium`. Uploads the Playwright report on failure. | Every run | -| `docs` | Build Docs | `turbo run build --filter='@object-ui/site'`. On a PR it first diffs against the base and skips the build when nothing under `apps/site/` or `content/` changed. It does **not** check docs links any more — that moved to `docs-links.yml` (#3448), because this workflow's `paths-ignore` hides exactly the docs-only PRs a link check needs to see. | Every run (build itself conditional) | +| `e2e` | Build & E2E | Builds the console with `vite build` (`VITE_BASE_PATH=/console/`), verifies the artifact, then `pnpm test:e2e --project=chromium`. Uploads the Playwright report on failure. | Every run; on a PR the steps short-circuit when only ignored paths changed | +| `docs` | Build Docs | `turbo run build --filter='@object-ui/site'`. On a PR it first diffs against the base and skips the build when nothing under `apps/site/` or `content/` changed. It does **not** check docs links any more — that moved to `docs-links.yml` (#3448), because this workflow's `paths-ignore` then hid exactly the docs-only PRs a link check needs to see. #3523 has since removed that filter from the `pull_request` trigger, but the check stays in its own home: `docs-links.yml` still runs where this workflow does not (a docs-only push to `main`), and one gate with one home was the point of #3448. | Every run (build itself conditional) | Uses: Node 22.x, pnpm via `corepack`, `actions/cache` over `.turbo/cache`. @@ -112,8 +172,11 @@ for them there is a dead end: ## Lint (`lint.yml`) -**Triggers:** Push and PR to `main`/`develop` (same `paths-ignore` as `ci.yml`, minus -`apps/site/**`), plus manual dispatch. +**Triggers:** **Every** PR to `main`/`develop` (no path filter), every merge-queue build, pushes +to `main`/`develop` under the same `paths-ignore` as `ci.yml` minus `apps/site/**`, plus manual +dispatch. As in `ci.yml`, the path list moved into the job ([#3523](https://github.com/objectstack-ai/objectui/issues/3523)): the `Lint` +context now reports on every pull request, and short-circuits to no install and no lint when only +ignored paths changed. This is a **real PR gate**, and it is easy to miss because it is not part of CI — it is its own **Lint** entry in the checks list. @@ -157,12 +220,17 @@ The two byte classes carry different harms and the report says which: Covering only U+0000 would reproduce a known miss: objectstack#5140 shipped a NUL *and* a U+0001 fourteen bytes away, and the NUL-only scanner reported OK on the second one (objectstack#5157). -**Why it is a separate workflow.** `ci.yml` and `lint.yml` both list `'**/*.md'`, `content/**`, -`docs/**` and `.changeset/**` under `paths-ignore`, and GitHub has no per-job path filter. Markdown +**Why it is a separate workflow.** `ci.yml` and `lint.yml` used to list `'**/*.md'`, `content/**`, +`docs/**` and `.changeset/**` under `paths-ignore` on *every* trigger, and GitHub has no per-job +path filter. Markdown is exactly the carrier the worst instance of this bug used — objectstack#4890 was a raw NUL in a `.claude/` skill file, emitted by the PR that was writing the rule forbidding it, leaving the agent instructions unfindable by `grep -r` with no signal that anything was missing. A path-filtered gate -could not have seen that PR. `scripts/__tests__/check-control-bytes.test.ts` fails if a `paths` or +could not have seen that PR. [#3523](https://github.com/objectstack-ai/objectui/issues/3523) has since taken that filter off their +`pull_request` triggers, so a markdown-only PR does start them now — but their jobs short-circuit +to nothing on such a change, and both keep the filter on `push`. This gate stays where it is, and +its unfiltered trigger set is why it is one of only two contexts that audit found safe to make +required today. `scripts/__tests__/check-control-bytes.test.ts` fails if a `paths` or `paths-ignore` key is ever added here. **If it fails:** write the escape sequence (backslash, lowercase `u`, four zeroes) instead of the @@ -358,6 +426,11 @@ red check for one broken link, and a second place to forget. pull requests, must carry neither `paths` nor `paths-ignore`, and must remain the only workflow that runs the script. +[#3523](https://github.com/objectstack-ai/objectui/issues/3523) removed `ci.yml`'s `pull_request` path filter, so the specific blindness above +no longer exists there — but nothing moves back. `ci.yml` still filters its `push` lane, so it +would miss a docs-only push to `main`; and this workflow is one of the two contexts that audit +found safe to require today precisely because it has never had a filter to reason about. + **If it fails:** it prints every offending `file -> href`. Either the link is misspelled, or the page it points at has moved or been renamed — fix the link, or restore the target. Links are checked as *routes*, so `/docs/guide/foo` is what belongs in the markdown, not @@ -423,8 +496,11 @@ Uses [Changesets](https://github.com/changesets/changesets) for automated versio ### Changeset Guard (`changeset-guard.yml`) **Trigger:** PR to `main`/`develop`, and push to `main`, **when `.changeset/**` changes** — the -inverse of every other workflow's filter. `ci.yml` and `lint.yml` both list `'**/*.md'` and -`.changeset/**` under `paths-ignore`, so a PR that adds only a changeset starts nothing else. +inverse of every other workflow's filter. It was carved out of `ci.yml` because `ci.yml` and +`lint.yml` listed `'**/*.md'` and `.changeset/**` under `paths-ignore`, so a PR that added only a +changeset started nothing at all. Since [#3523](https://github.com/objectstack-ai/objectui/issues/3523) such a PR does start both — and every +job in them short-circuits, because `.changeset/**` is still on the in-job ignore list. The check +that has to read the changeset therefore still lives here. Runs `scripts/check-changeset-no-major.mjs`, which fails if any pending changeset declares a `major` bump. Every publishable package is in one `fixed` group (39 packages), so a single diff --git a/scripts/__tests__/lint-workflow.test.ts b/scripts/__tests__/lint-workflow.test.ts index 7fa2737a5..944dbb5af 100644 --- a/scripts/__tests__/lint-workflow.test.ts +++ b/scripts/__tests__/lint-workflow.test.ts @@ -172,15 +172,31 @@ describe('lint.yml header comment — the claims it still makes', () => { ).toMatch(/^\s{2}pull_request:/m); }); - it('does not paths-ignore the TypeScript sources those rules lint', () => { + it('does not skip the TypeScript sources those rules lint', () => { // A narrow tripwire, not a glob engine (the repo has no glob matcher at the // root, and vendoring one for this would cost more than it protects). The // realistic way to un-gate the ratchets without touching the triggers above - // is adding a TypeScript pattern to `paths-ignore`; today's entries are all + // is adding a TypeScript pattern to the ignore list; today's entries are all // markdown, `content/**`, `docs/**` and `.changeset/**`. - const ignored = [...onBlock().matchAll(/^\s*-\s*'([^']+)'/gm)].map((m) => m[1]); + // + // That list now lives in TWO places and both have to be read, which is the + // objectui#3523 change: `paths-ignore` stayed on the `push` trigger, but for + // pull requests it moved INTO the job, as `:(exclude,glob)…` pathspecs in + // the `Decide whether this change needs a full run` step. Reading only the + // `on:` block would have gone on passing while a `**/*.ts` exclusion was + // added to the job — the gate reporting green having linted nothing. + // `merge-queue-reporting.test.ts` pins the two lists to each other and pins + // why the trigger may not filter pull requests at all; this one stays on its + // own question, which is what may be in the list. + const ignored = [ + ...[...onBlock().matchAll(/^\s*-\s*'([^']+)'/gm)].map((m) => m[1]), + ...[...workflow.matchAll(/':\(exclude,glob\)([^']+)'/g)].map((m) => m[1]), + ]; - expect(ignored.length, 'lint.yml must still declare `paths-ignore` entries').toBeGreaterThan(0); + expect( + ignored.length, + 'lint.yml must still declare an ignore list — on the `push` trigger, in the job, or both', + ).toBeGreaterThan(0); const swallowsSource = ignored.filter((pattern) => /\.tsx?$/.test(pattern)); expect( @@ -189,7 +205,8 @@ describe('lint.yml header comment — the claims it still makes', () => { swallowsSource.map((p) => ` - ${p}`).join('\n') + `\n\nEvery \`object-ui/*\` rule \`eslint.config.js\` sets to \`error\` only gates ` + `a PR because this workflow lints the .ts/.tsx it touches. Excluding them is the ` + - `pre-#2923 inert state with extra steps.`, + `pre-#2923 inert state with extra steps — and since objectui#3523 it is the quieter ` + + `version of it, because the \`Lint\` check still reports, green.`, ).toEqual([]); }); }); diff --git a/scripts/__tests__/merge-queue-reporting.test.ts b/scripts/__tests__/merge-queue-reporting.test.ts new file mode 100644 index 000000000..fd6b9366f --- /dev/null +++ b/scripts/__tests__/merge-queue-reporting.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * objectui#3523 — the merge queue was enforced and validated nothing. + * + * Two independent holes produced one P0, and this file pins both shut, because + * each of them is invisible in exactly the way that makes CI look healthy. + * + * 1. **Nothing subscribed `merge_group`.** A ruleset requires every change to + * land through the merge queue (#3243 measured a direct push to `main` + * returning 405 `Changes must be made through the merge queue`), yet not one + * of the repository's workflows carried the trigger — repo-wide + * `event=merge_group` runs stood at total_count = 0, historically. A queue + * nothing subscribes to can only carry an EMPTY required-check set, so it + * rebuilt each pull request on the current `main` and let it through without + * running anything. On 2026-08-07 #3503, #3510 and #3516 all merged with + * `Type Check` at conclusion=failure, on a `main` that #3498 had left with a + * TS2578; #3505 hot-fixed the result. + * + * 2. **`paths-ignore` on the `pull_request` trigger.** `paths-ignore` skips the + * WHOLE workflow when every changed file matches, and GitHub has no per-job + * path filter, so a docs-only or changeset-only PR started neither `ci.yml` + * nor `lint.yml` — #3509 measured zero check runs from them. A check that is + * never created does not fail a required-status-check rule, it leaves the PR + * pending forever; inside the queue it fails on the ruleset's 60-minute + * status-check timeout. So none of those contexts could be made required + * while the filter lived on the trigger, which is why the queue's required + * set was empty in the first place. `control-bytes.yml` and `docs-links.yml` + * had already reached this conclusion for themselves — their headers say a + * gate a markdown-only PR cannot start "rebuilds the hole it exists to + * close" — but the two heavyweight workflows had not. + * + * The fix is deliberately ordered: subscribe the trigger first (pure addition), + * then move the path decision from the trigger into the jobs, and only then may + * a maintainer write these contexts into the branch-protection and queue + * required sets. Reversing that order deadlocks the whole repository, which is + * what the assertions below exist to prevent someone re-doing by halves. + * + * Deliberately NOT asserted: which contexts are actually required. That lives in + * repository settings, which no test in this repo can read and no agent may + * change. + */ +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const workflowDir = path.join(repoRoot, '.github/workflows'); + +/** + * `filename -> why this workflow must subscribe merge_group`. + * + * A hand-maintained list, and it has to be: "may this context be required?" is a + * property of the repository's settings, not of the YAML, so nothing mechanical + * can derive the set. What IS mechanical is the honesty check below — an entry + * naming a workflow that no longer exists fails, so the list cannot rot into a + * comfortable fiction the way a stale count does (#3261). + */ +const MUST_SUBSCRIBE_MERGE_GROUP = new Map([ + ['ci.yml', 'produces Type Check, Build & E2E, Test (shard N/4) and Changeset Fixed Group Check'], + ['lint.yml', 'produces Lint — the ESLint error ratchets'], + ['control-bytes.yml', 'produces Control Byte Scan, one of the two contexts #3523 found safe to require today'], + ['docs-links.yml', 'produces Internal Docs Link Check, the other one'], +]); + +/** Workflows whose path filtering had to move from the trigger into the jobs. */ +const FILTER_MOVED_INTO_JOBS = ['ci.yml', 'lint.yml']; + +const read = (file: string): string => fs.readFileSync(path.join(workflowDir, file), 'utf8'); + +/** + * A workflow's YAML with whole-line comments removed. Required, not cosmetic: + * every one of these files discusses `paths-ignore`, `merge_group` and the + * incident above in prose, and a scan that counted the prose would report + * triggers and filters that no file has. + */ +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')); + expect(at, `the workflow must still have a top-level \`${key}:\``).toBeGreaterThan(-1); + const rest = yaml.slice(at); + const firstLineEnd = rest.indexOf('\n') + 1; + 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); +} + +/** `- 'pattern'` entries — the shape `paths` / `paths-ignore` lists are written in. */ +const quotedEntries = (block: string): string[] => + [...block.matchAll(/^\s*-\s*'([^']+)'/gm)].map((m) => m[1]); + +/** `':(exclude,glob)pattern'` pathspecs — the shape the in-job gate is written in. */ +const excludePathspecs = (yaml: string): string[] => + [...yaml.matchAll(/':\(exclude,glob\)([^']+)'/g)].map((m) => m[1]); + +describe('every requirable context reports on a merge-queue build (#3523 step 1)', () => { + it('subscribes merge_group in each workflow that produces one', () => { + const missing = [...MUST_SUBSCRIBE_MERGE_GROUP.keys()].filter( + (file) => !/^ {2}merge_group:/m.test(topLevelBlock(withoutComments(read(file)), 'on')), + ); + + expect( + missing, + `These workflows produce a check the repository can require, but do not subscribe ` + + `\`merge_group\`:\n` + + missing.map((f) => ` - ${f} (${MUST_SUBSCRIBE_MERGE_GROUP.get(f)})`).join('\n') + + `\n\nThe merge queue is ENFORCED here (#3243). A required context that does not report ` + + `on a queue build does not fail the queue, it stalls it until the ruleset's 60-minute ` + + `status-check timeout — and while nothing at all subscribes, the queue's required set ` + + `can only be empty, so it rebuilds each PR on the current \`main\` and merges it ` + + `unvalidated. That is not hypothetical: #3503 / #3510 / #3516 merged on 2026-08-07 with ` + + `\`Type Check\` at conclusion=failure (objectui#3523).`, + ).toEqual([]); + }); + + it('keeps the list honest — every name in it is a workflow that exists', () => { + const files = new Set(fs.readdirSync(workflowDir).filter((f) => f.endsWith('.yml'))); + for (const [name, reason] of MUST_SUBSCRIBE_MERGE_GROUP) { + expect(files, `MUST_SUBSCRIBE_MERGE_GROUP names ${name}, which no longer exists — drop it`).toContain(name); + expect(reason.length, `MUST_SUBSCRIBE_MERGE_GROUP[${name}] must say which context it produces`).toBeGreaterThan(20); + } + }); + + it('runs the test suite on a queue build, not only on pull requests', () => { + // `if: github.event_name == 'pull_request'` predates the queue and was + // correct while the third event could not happen. Left alone it would make + // the last validation before `main` the ONLY one that skips every shard. + const jobs = topLevelBlock(withoutComments(read('ci.yml')), 'jobs'); + const test = nestedBlock(jobs, 'test'); + expect(test, 'ci.yml must still define a `test:` job').not.toEqual(''); + expect( + test, + `ci.yml's \`test\` job is restricted to \`pull_request\`, so a merge_group build runs no ` + + `tests at all. Write the exclusion as "not push" instead — \`test-coverage\` is the push ` + + `lane, and the queue build is the last check before \`main\` (objectui#3523).`, + ).not.toMatch(/^\s*if: github\.event_name == 'pull_request'\s*$/m); + }); +}); + +describe('every context reports on every pull request (#3523 step 2)', () => { + it.each(FILTER_MOVED_INTO_JOBS)('%s filters no pull request at the trigger', (file) => { + const pullRequest = nestedBlock(topLevelBlock(withoutComments(read(file)), 'on'), 'pull_request'); + expect(pullRequest, `${file} must still trigger on \`pull_request\``).not.toEqual(''); + + for (const key of ['paths-ignore', 'paths']) { + expect( + pullRequest, + `${file} filters its \`pull_request\` trigger by \`${key}\`. That skips the WHOLE ` + + `workflow — GitHub has no per-job path filter — so on a PR whose files all match, the ` + + `contexts this file produces are never created. A required check that is never created ` + + `leaves the PR pending forever and fails a queue build on the 60-minute timeout, which ` + + `is why they could not be required at all before objectui#3523 (#3509 measured a ` + + `docs-only PR starting zero of them). Put the path decision in the jobs instead, the ` + + `way \`ci.yml\`'s \`docs\` job has since #3450.`, + ).not.toMatch(new RegExp(`^\\s*${key}:`, 'm')); + } + }); + + it.each(FILTER_MOVED_INTO_JOBS)('%s keeps ONE ignore list, on the push trigger', (file) => { + // The push lane keeps its `paths-ignore` — branch protection and the merge + // queue judge pull requests and queue builds, never pushes to `main`, so + // filtering there costs nothing and saves a full run on every docs merge. + // That makes it the single authored home for the list, and the in-job gates + // are held to it here rather than being four hand-synced copies (#3261's + // lesson: a hand-copied enumeration drifts by construction). + const push = nestedBlock(topLevelBlock(withoutComments(read(file)), 'on'), 'push'); + const declared = quotedEntries(push); + expect( + declared.length, + `${file}'s \`push\` trigger no longer declares \`paths-ignore\` entries — the in-job gates ` + + `below have nothing left to be checked against.`, + ).toBeGreaterThan(0); + + const inJob = [...new Set(excludePathspecs(read(file)))]; + expect( + inJob.length, + `${file} declares no \`:(exclude,glob)…\` pathspec, so no job short-circuits and the ` + + `expensive steps now run on every docs-only PR. objectui#3523 moved the filter into the ` + + `jobs; it did not delete it.`, + ).toBeGreaterThan(0); + + expect( + [...inJob].sort(), + `${file}'s in-job exclusion list has drifted from the \`paths-ignore\` on its \`push\` ` + + `trigger. The two must stay identical: the trigger is what the in-job gate replaced for ` + + `pull requests, and any difference silently means PRs and pushes are judged by different ` + + `rules (objectui#3523).`, + ).toEqual([...new Set(declared)].sort()); + }); + + it.each(FILTER_MOVED_INTO_JOBS)('%s gates every step after the gate, in every gated job', (file) => { + const jobs = topLevelBlock(withoutComments(read(file)), 'jobs'); + const keys = [...jobs.matchAll(/^ {2}([a-z0-9][a-z0-9-]*):[ \t]*$/gm)].map((m) => m[1]); + expect(keys.length, `the ${file} \`jobs:\` parse returned implausibly few keys`).toBeGreaterThan(0); + + const gatedJobs = keys.filter((key) => /^\s*id: relevant$/m.test(nestedBlock(jobs, key))); + expect( + gatedJobs.length, + `${file} has no job carrying the \`id: relevant\` short-circuit. Removing it does not make ` + + `the gate stricter — it makes every expensive step run on every docs-only PR ` + + `(objectui#3523).`, + ).toBeGreaterThan(0); + + for (const key of gatedJobs) { + const block = nestedBlock(jobs, key); + const after = block.slice(block.search(/^\s*id: relevant$/m)); + // Step boundaries inside a job body: ` - name: …`. + const steps = after.split(/^ {6}- name: /m).slice(1); + expect(steps.length, `${file}'s \`${key}\` job has no steps after its gate`).toBeGreaterThan(0); + + const ungated = steps + .filter((step) => !step.includes('steps.relevant.outputs.should_run')) + .map((step) => step.split('\n')[0].trim()); + + expect( + ungated, + `${file}'s \`${key}\` job runs these steps regardless of its own short-circuit:\n` + + ungated.map((s) => ` - ${s}`).join('\n') + + `\n\nEvery step after \`id: relevant\` must carry ` + + `\`if: steps.relevant.outputs.should_run == 'true'\` (combined with its own condition ` + + `where it already has one). A step that ignores the gate turns a docs-only PR into a ` + + `full install-and-build, which is the cost objectui#3523 kept while moving the filter.`, + ).toEqual([]); + } + }); + + it('fails OPEN: a gate that cannot compute the diff runs everything', () => { + // The direction matters more than the code. objectstack#4928 named this the + // filter contract after the opposite spelling — a diff whose failure was + // swallowed into an empty result — produced a fully green pull request with + // no job having run and no red signal anywhere. `ci.yml`'s older `docs` gate + // still uses that fail-CLOSED spelling (`|| echo ""`); the gates added by + // objectui#3523 must not copy it. + for (const file of FILTER_MOVED_INTO_JOBS) { + const yaml = read(file); + const gates = [...yaml.matchAll(/^\s*id: relevant$/gm)]; + expect(gates.length, `${file} must still carry at least one \`id: relevant\` gate`).toBeGreaterThan(0); + + expect( + yaml, + `${file}'s short-circuit swallows a failed \`git diff\` into an empty result, which reads ` + + `as "nothing relevant changed" and skips every gate. When the filter cannot tell, it ` + + `must RUN (objectstack#4928).`, + ).toMatch(/if ! CHANGED=\$\(git diff --name-only/); + } + }); +});