From 0d333e3fe609e6d7ea3a5bd145fd71c8032de056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 14:26:09 +0200 Subject: [PATCH 01/11] ci: temporary fallow timing probe --- .github/workflows/fallow-probe.yml | 48 ++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/fallow-probe.yml diff --git a/.github/workflows/fallow-probe.yml b/.github/workflows/fallow-probe.yml new file mode 100644 index 0000000000..57d26e4ff3 --- /dev/null +++ b/.github/workflows/fallow-probe.yml @@ -0,0 +1,48 @@ +name: Fallow probe (temporary) + +on: + pull_request: + paths: + - '.github/workflows/fallow-probe.yml' + +permissions: + contents: read + +jobs: + probe: + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + BASE: ${{ github.event.pull_request.base.sha }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + - uses: ./.github/actions/setup-node-pnpm + - name: Host + run: | + nproc; free -g; df -h .; uname -a + FALLOW_VERIFY_LOG=1 pnpm exec fallow --version + ls node_modules/.pnpm | grep -i fallow + - name: dead-code (cold) + run: rm -rf .fallow; time pnpm exec fallow dead-code --performance --summary || true + - name: dead-code (warm) + run: time pnpm exec fallow dead-code --performance --summary || true + - name: dead-code threads 4 (cold) + run: rm -rf .fallow; time pnpm exec fallow dead-code --performance --summary --threads 4 || true + - name: dupes (cold) + run: rm -rf .fallow; time pnpm exec fallow dupes --performance --summary || true + - name: health (cold) + run: rm -rf .fallow; time pnpm exec fallow health --performance --summary || true + - name: audit (cold, current gate) + run: rm -rf .fallow; time pnpm exec fallow audit --base "$BASE" --performance || true + - name: audit gate all (cold) + run: rm -rf .fallow; time pnpm exec fallow audit --base "$BASE" --gate all --performance || true + - name: production-exports (cold) + run: rm -rf .fallow; time pnpm check:production-exports --performance || true + - name: strace summary of dead-code + run: | + rm -rf .fallow + sudo apt-get install -y strace >/dev/null 2>&1 || true + timeout 600 strace -f -c -o /tmp/strace.txt pnpm exec fallow dead-code --summary >/dev/null 2>&1 || true + head -30 /tmp/strace.txt From 29e3c10c7812147ab1156356aac2af57ab3991b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 15:20:42 +0200 Subject: [PATCH 02/11] ci: probe statx paths --- .github/workflows/fallow-probe.yml | 32 +++++++++++------------------- scripts/gate/workflows.test.ts | 23 +++++++++++++++++++++ scripts/gate/workflows.ts | 12 +++++++++-- 3 files changed, 45 insertions(+), 22 deletions(-) diff --git a/.github/workflows/fallow-probe.yml b/.github/workflows/fallow-probe.yml index 57d26e4ff3..92a9895c83 100644 --- a/.github/workflows/fallow-probe.yml +++ b/.github/workflows/fallow-probe.yml @@ -24,25 +24,17 @@ jobs: nproc; free -g; df -h .; uname -a FALLOW_VERIFY_LOG=1 pnpm exec fallow --version ls node_modules/.pnpm | grep -i fallow - - name: dead-code (cold) - run: rm -rf .fallow; time pnpm exec fallow dead-code --performance --summary || true - - name: dead-code (warm) - run: time pnpm exec fallow dead-code --performance --summary || true - - name: dead-code threads 4 (cold) - run: rm -rf .fallow; time pnpm exec fallow dead-code --performance --summary --threads 4 || true - - name: dupes (cold) - run: rm -rf .fallow; time pnpm exec fallow dupes --performance --summary || true - - name: health (cold) - run: rm -rf .fallow; time pnpm exec fallow health --performance --summary || true - - name: audit (cold, current gate) - run: rm -rf .fallow; time pnpm exec fallow audit --base "$BASE" --performance || true - - name: audit gate all (cold) - run: rm -rf .fallow; time pnpm exec fallow audit --base "$BASE" --gate all --performance || true - - name: production-exports (cold) - run: rm -rf .fallow; time pnpm check:production-exports --performance || true - - name: strace summary of dead-code + - name: sample statx paths during resolve run: | - rm -rf .fallow sudo apt-get install -y strace >/dev/null 2>&1 || true - timeout 600 strace -f -c -o /tmp/strace.txt pnpm exec fallow dead-code --summary >/dev/null 2>&1 || true - head -30 /tmp/strace.txt + rm -rf .fallow + timeout 90 strace -f -e trace=statx,newfstatat,openat,readlink -o /tmp/s.txt pnpm exec fallow dead-code --summary >/dev/null 2>&1 || true + wc -l /tmp/s.txt + echo "== top 60 paths" + grep -o '"[^"]*"' /tmp/s.txt | sort | uniq -c | sort -rn | head -60 + echo "== top 40 parent dirs" + grep -o '"[^"]*"' /tmp/s.txt | tr -d '"' | xargs -r -n1 dirname 2>/dev/null | sort | uniq -c | sort -rn | head -40 || true + echo "== top 40 basenames" + grep -o '"[^"]*"' /tmp/s.txt | tr -d '"' | awk -F/ '{print $NF}' | sort | uniq -c | sort -rn | head -40 + echo "== ENOENT share" + grep -c ENOENT /tmp/s.txt || true diff --git a/scripts/gate/workflows.test.ts b/scripts/gate/workflows.test.ts index 21c43e8919..06a6b5b13d 100644 --- a/scripts/gate/workflows.test.ts +++ b/scripts/gate/workflows.test.ts @@ -221,6 +221,29 @@ jobs: assert.ok(caller?.uses.includes('.github/workflows/called.yml')); }); +test('gates inside a parallel step group belong to the lane, alongside sequential ones', () => { + const lanes = planted({ + '.github/workflows/planted.yml': `name: Planted +on: + pull_request: +jobs: + planted: + steps: + - parallel: + - uses: ./.github/actions/run-gate + with: + gate: layering + - uses: ./.github/actions/run-gate + with: + gate: lint + - uses: ./.github/actions/run-gate + with: + gate: format +`, + }); + assert.deepEqual(lanes[0]?.gates, ['layering', 'lint', 'format']); +}); + test('a remote or nested reusable workflow fails closed', () => { const lanes = planted({ '.github/workflows/planted.yml': `name: Planted diff --git a/scripts/gate/workflows.ts b/scripts/gate/workflows.ts index 1672a85430..b711641ca4 100644 --- a/scripts/gate/workflows.ts +++ b/scripts/gate/workflows.ts @@ -36,6 +36,7 @@ type RawStep = { uses?: string; run?: string; with?: Record; + parallel?: RawStep[]; }; type Job = { name?: string; steps?: RawStep[]; uses?: string; with?: Record }; @@ -179,6 +180,11 @@ function readLocalWorkflowJobs(uses: string, root: string): Job[] | null { return jobs.length > 0 ? jobs : null; } +/** A job's steps with each `parallel:` group opened into the steps it runs. */ +function runnableSteps(steps: readonly RawStep[]): RawStep[] { + return steps.flatMap((step) => (step.parallel ? runnableSteps(step.parallel) : [step])); +} + /** What a job runs, with the caller's `with` inputs that the steps' `${{ inputs.* }}` resolve to. */ type JobSteps = { steps: RawStep[]; @@ -192,13 +198,15 @@ type JobSteps = { * missing, jobless, or nested reusable workflow fails closed as unsupported. */ function jobSteps(job: Job, root: string): JobSteps { - if (!job.uses) return { steps: job.steps ?? [], inputs: {}, files: [], unsupported: [] }; + if (!job.uses) { + return { steps: runnableSteps(job.steps ?? []), inputs: {}, files: [], unsupported: [] }; + } const called = readLocalWorkflowJobs(job.uses, root); if (!called || called.some((calledJob) => calledJob.uses)) { const unsupported = [`\`uses: ${job.uses}\` (reusable workflow)`]; return { steps: [], inputs: {}, files: [], unsupported }; } - const steps = called.flatMap((calledJob) => calledJob.steps ?? []); + const steps = called.flatMap((calledJob) => runnableSteps(calledJob.steps ?? [])); return { steps, inputs: job.with ?? {}, files: [job.uses.slice(2)], unsupported: [] }; } From adfe685b7f95cf8b8b85245e0ea54647ccb65d83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 15:20:50 +0200 Subject: [PATCH 03/11] ci: probe dirname via awk --- .github/workflows/fallow-probe.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/fallow-probe.yml b/.github/workflows/fallow-probe.yml index 92a9895c83..0c23ec4e32 100644 --- a/.github/workflows/fallow-probe.yml +++ b/.github/workflows/fallow-probe.yml @@ -33,7 +33,7 @@ jobs: echo "== top 60 paths" grep -o '"[^"]*"' /tmp/s.txt | sort | uniq -c | sort -rn | head -60 echo "== top 40 parent dirs" - grep -o '"[^"]*"' /tmp/s.txt | tr -d '"' | xargs -r -n1 dirname 2>/dev/null | sort | uniq -c | sort -rn | head -40 || true + grep -o '"[^"]*"' /tmp/s.txt | tr -d '"' | awk '{sub(/\/[^\/]*$/, ""); print}' | sort | uniq -c | sort -rn | head -40 echo "== top 40 basenames" grep -o '"[^"]*"' /tmp/s.txt | tr -d '"' | awk -F/ '{print $NF}' | sort | uniq -c | sort -rn | head -40 echo "== ENOENT share" From 1d8f508e2ccc14314080325fd26e3834e7ed1983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 15:26:02 +0200 Subject: [PATCH 04/11] ci: probe warm fallow cache and parallel step syntax --- .github/workflows/fallow-probe.yml | 47 ++++++++++++++++++------------ 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/.github/workflows/fallow-probe.yml b/.github/workflows/fallow-probe.yml index 0c23ec4e32..dffd7e1a74 100644 --- a/.github/workflows/fallow-probe.yml +++ b/.github/workflows/fallow-probe.yml @@ -9,7 +9,7 @@ permissions: contents: read jobs: - probe: + warm-from-base: runs-on: ubuntu-latest timeout-minutes: 60 env: @@ -19,22 +19,31 @@ jobs: with: fetch-depth: 0 - uses: ./.github/actions/setup-node-pnpm - - name: Host + - name: Build the cache at the base commit (what a main push would save) run: | - nproc; free -g; df -h .; uname -a - FALLOW_VERIFY_LOG=1 pnpm exec fallow --version - ls node_modules/.pnpm | grep -i fallow - - name: sample statx paths during resolve - run: | - sudo apt-get install -y strace >/dev/null 2>&1 || true - rm -rf .fallow - timeout 90 strace -f -e trace=statx,newfstatat,openat,readlink -o /tmp/s.txt pnpm exec fallow dead-code --summary >/dev/null 2>&1 || true - wc -l /tmp/s.txt - echo "== top 60 paths" - grep -o '"[^"]*"' /tmp/s.txt | sort | uniq -c | sort -rn | head -60 - echo "== top 40 parent dirs" - grep -o '"[^"]*"' /tmp/s.txt | tr -d '"' | awk '{sub(/\/[^\/]*$/, ""); print}' | sort | uniq -c | sort -rn | head -40 - echo "== top 40 basenames" - grep -o '"[^"]*"' /tmp/s.txt | tr -d '"' | awk -F/ '{print $NF}' | sort | uniq -c | sort -rn | head -40 - echo "== ENOENT share" - grep -c ENOENT /tmp/s.txt || true + git checkout -q "$BASE" + time pnpm exec fallow dead-code --summary || true + time pnpm check:production-exports || true + du -sh .fallow + git checkout -q "$GITHUB_SHA" + - name: audit on head, cache from base + run: time pnpm gate fallow --base "$BASE" || true + - name: production-exports on head, cache from base + run: time pnpm gate production-exports || true + - name: health on head, cache from base + run: time pnpm exec fallow health --summary --performance || true + - name: audit again (fully warm) + run: time pnpm gate fallow --base "$BASE" || true + + parallel-syntax: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - id: slow + run: sleep 20; echo "out=slow" >> "$GITHUB_OUTPUT" + background: true + - parallel: + - run: sleep 10; echo a + - run: sleep 10; echo b + - wait: slow + - run: echo "slow said ${{ steps.slow.outputs.out }}" From 365a0f5e9d3a5c74af8790727ec0780d130e92a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 15:27:40 +0200 Subject: [PATCH 05/11] ci: cache fallow, fold compat into repo guards and lint into typecheck with parallel steps A cold fallow run re-resolves every import against the root tsconfig's 28 project references (~760 statx per import), which takes minutes on a hosted runner; the persisted parse cache skips unchanged files. Main pushes save the cache, every run restores it. Parallel steps let the CI workflow drop from six jobs to four without lengthening any lane: freerange runs in the background beside the other guards, and lint/format/DI-seams run beside typecheck. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 194 ++++++++++++++++++++------------------- 1 file changed, 102 insertions(+), 92 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d5073aa57..e1287c41ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,13 +30,16 @@ concurrency: cancel-in-progress: true jobs: + # Lint, typecheck, and package verification share one checkout and install. The lint gates + # and typecheck are independent reads of the tree, so they run as one parallel group. + # # The Swift trailing-comma assertion is text-only and runs before the toolchain setup, so a # grep failure does not wait on an install. The DI-seams check below needs a real TypeScript # runtime (#1976 / PR #2006), so it runs after Setup toolchain instead. - lint: - name: Lint & Format + lint-typecheck-package: + name: Lint, Typecheck & Package runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -69,51 +72,98 @@ jobs: - name: Setup toolchain uses: ./.github/actions/setup-node-pnpm - # Same false-green shape as the Swift check above (#1976). An earlier revision of this - # gate (PR #2006, first review pass) fixed the exit-code handling but kept the ban/allow - # decision as a regex that exempted matches by the *spelling* of the typeof target - # (`typeof fetch` always passed, SCREAMING_SNAKE_CASE targets always passed) — a name-based - # semantic allowlist that would silently pass a new, genuinely test-only `typeof fetch` seam - # anywhere in the tree while banning an equally legitimate seam under any other name. - # scripts/di-seams instead checks each match against an explicit, typed, per-site allowlist - # (scripts/di-seams/approved.ts) keyed by (file, field, typeof-target): a triple is exempt - # only because it was individually reviewed and named, never because of how it is spelled. - # The gate fails just as hard on a stale approval (one whose triple no longer matches - # anything) as on an unapproved seam, so the allowlist can't drift out of sync with the code - # it describes. See scripts/di-seams/model.ts and its tests. - - name: Fail if test-only DI seams reappear in production code - uses: ./.github/actions/run-gate - with: { gate: di-seams } + - parallel: + # Same false-green shape as the Swift check above (#1976). An earlier revision of this + # gate (PR #2006, first review pass) fixed the exit-code handling but kept the ban/allow + # decision as a regex that exempted matches by the *spelling* of the typeof target + # (`typeof fetch` always passed, SCREAMING_SNAKE_CASE targets always passed) — a name-based + # semantic allowlist that would silently pass a new, genuinely test-only `typeof fetch` seam + # anywhere in the tree while banning an equally legitimate seam under any other name. + # scripts/di-seams instead checks each match against an explicit, typed, per-site allowlist + # (scripts/di-seams/approved.ts) keyed by (file, field, typeof-target): a triple is exempt + # only because it was individually reviewed and named, never because of how it is spelled. + # The gate fails just as hard on a stale approval (one whose triple no longer matches + # anything) as on an unapproved seam, so the allowlist can't drift out of sync with the code + # it describes. See scripts/di-seams/model.ts and its tests. + - name: Fail if test-only DI seams reappear in production code + uses: ./.github/actions/run-gate + with: { gate: di-seams } + + - name: Run oxlint + uses: ./.github/actions/run-gate + with: { gate: lint } + + - name: Check formatting + uses: ./.github/actions/run-gate + with: { gate: format } + + - name: Run typecheck + uses: ./.github/actions/run-gate + with: { gate: typecheck } - - name: Run oxlint + - name: Build CLI uses: ./.github/actions/run-gate - with: { gate: lint } + with: { gate: build } - - name: Check formatting + - name: Verify emitted chunk ownership uses: ./.github/actions/run-gate - with: { gate: format } + with: { gate: bundle-owner-files } - # Structural guards share one checkout and install. Every gate stays an - # independently named step with its own failure message; the gate manifest - # derives lane ownership from these declarations, so merging jobs changes - # nothing it asserts. + # The build runs on the default toolchain Node and the package is verified on the minimum + # supported Node, so this job covers what a user on `engines.node` floor actually installs. + - name: Setup Node.js 22.12 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: '22.12' + + # Packs, lints the tarball with publint/attw, installs it outside the workspace, and imports + # every published entry point before running the CLI. See scripts/check-package.ts. + # + # Runs the script directly rather than through `pnpm check:package`: the repo's pinned pnpm + # requires Node >= 22.13 and refuses to start on the 22.12 floor this job exists to cover. The + # gate itself only needs `node` and `npm`, so it is the package.json script minus the launcher. + - name: Verify the published package on Node.js 22.12 + run: node --experimental-strip-types scripts/check-package.ts + + # Structural guards and the history-backed compatibility gates share one deep checkout and + # install. Every gate stays an independently named step with its own failure message; the gate + # manifest derives lane ownership from these declarations, so merging jobs changes nothing it + # asserts. repo-guards: - name: Repo Guards + name: Repo Guards & Compatibility runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 steps: # The layering ratchets (R6/R9/R10) measure the merge-base with origin/main, which a - # shallow checkout cannot reach. + # shallow checkout cannot reach. Fallow, replay-compat, and daemon-wire-compat need full + # history or tags. - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + fetch-tags: true # The layering gate parses production sources with `oxc-parser`, so # dependencies are required; keep install-deps enabled. - name: Setup toolchain uses: ./.github/actions/setup-node-pnpm + # Slowest guard (~4.5 min) and independent of the others, so it runs beside them. + - name: Check numeric ranges + id: freerange + background: true + uses: ./.github/actions/run-gate + with: { gate: freerange } + + # A cold fallow run re-resolves every import against the root tsconfig's project + # references, which costs minutes on a hosted runner; the parse cache skips unchanged files. + - name: Restore fallow cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.2.3 + with: + path: .fallow + key: fallow-${{ github.job }}-${{ github.sha }} + restore-keys: fallow-${{ github.job }}- + - name: Check import-direction DAG # Structured import-direction lint over the resolved graph. See # scripts/layering/check.ts and CONTEXT.md (Architecture: folder DAG + @@ -186,29 +236,6 @@ jobs: uses: ./.github/actions/run-gate with: { gate: mcp-metadata } - # Slowest guard (~3 min), so it runs last and structural failures surface - # before it. Runs on plain Node via the shared install above. - - name: Check numeric ranges - uses: ./.github/actions/run-gate - with: { gate: freerange } - - # History-backed compatibility gates share one deep checkout: fallow, - # replay-compat, and daemon-wire-compat all need either full history or tags, - # which no shallow unit lane can read. - compat-provenance: - name: Compatibility & Provenance - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Setup toolchain - uses: ./.github/actions/setup-node-pnpm - - name: Run Fallow audit env: FALLOW_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} @@ -235,52 +262,20 @@ jobs: # The daemon RPC wire ledger (#1432) is compared against the ledger as it # stood at the last RELEASED tag, which only a tagged checkout can read. - # The shallow Repo Guards lane holds the ledger to its source; this step - # holds it to the last release. - name: Compare the daemon RPC wire surface against the last released tag uses: ./.github/actions/run-gate with: { gate: daemon-wire-compat } - # Typecheck and package verification share one checkout and install; both - # need the default toolchain only, and the package step re-pins Node itself. - typecheck-package: - name: Typecheck & Package - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Setup toolchain - uses: ./.github/actions/setup-node-pnpm - - - name: Run typecheck - uses: ./.github/actions/run-gate - with: { gate: typecheck } - - - name: Build CLI - uses: ./.github/actions/run-gate - with: { gate: build } - - - name: Verify emitted chunk ownership - uses: ./.github/actions/run-gate - with: { gate: bundle-owner-files } - - # The build runs on the default toolchain Node and the package is verified on the minimum - # supported Node, so this job covers what a user on `engines.node` floor actually installs. - - name: Setup Node.js 22.12 - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + # Main pushes refresh the cache PR runs restore; PR runs only read it. + - name: Save fallow cache + if: ${{ github.event_name == 'push' }} + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.2.3 with: - node-version: '22.12' + path: .fallow + key: fallow-${{ github.job }}-${{ github.sha }} - # Packs, lints the tarball with publint/attw, installs it outside the workspace, and imports - # every published entry point before running the CLI. See scripts/check-package.ts. - # - # Runs the script directly rather than through `pnpm check:package`: the repo's pinned pnpm - # requires Node >= 22.13 and refuses to start on the 22.12 floor this job exists to cover. The - # gate itself only needs `node` and `npm`, so it is the package.json script minus the launcher. - - name: Verify the published package on Node.js 22.12 - run: node --experimental-strip-types scripts/check-package.ts + - name: Wait for numeric ranges + wait: freerange # Runs the full unit + provider-integration suites under coverage with # thresholds, so a separate unit-tests job would rerun the same tests. @@ -297,6 +292,14 @@ jobs: - name: Setup toolchain uses: ./.github/actions/setup-node-pnpm + # The CRAP steps score through `fallow health`; see Repo Guards for why its cache matters. + - name: Restore fallow cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.2.3 + with: + path: .fallow + key: fallow-${{ github.job }}-${{ github.sha }} + restore-keys: fallow-${{ github.job }}- + - name: Test changed-line coverage gate uses: ./.github/actions/run-gate with: { gate: coverage-model } @@ -366,6 +369,13 @@ jobs: gate: coverage-crap args: --all + - name: Save fallow cache + if: ${{ !cancelled() && github.event_name == 'push' }} + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.2.3 + with: + path: .fallow + key: fallow-${{ github.job }}-${{ github.sha }} + integration: name: Integration Tests runs-on: ubuntu-latest From c7404c5cd485242b7c3bff88ee38906b3666d8da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 16:54:50 +0200 Subject: [PATCH 06/11] ci: run the fallow audit in the background and drop the fallow cache The persisted cache only helps an identical tree: a PR head restored from its base still re-resolves every import (524s audit, 209s health on CI). Starting the audit beside freerange and the light guards takes it off the job's sequential path instead. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 64 ++++++++++-------------------- .github/workflows/fallow-probe.yml | 49 ----------------------- 2 files changed, 20 insertions(+), 93 deletions(-) delete mode 100644 .github/workflows/fallow-probe.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1287c41ae..cbe0ff2a2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,21 +148,26 @@ jobs: - name: Setup toolchain uses: ./.github/actions/setup-node-pnpm - # Slowest guard (~4.5 min) and independent of the others, so it runs beside them. + # The two slowest gates run in the background beside the light guards below. A cold fallow + # run costs several minutes on a hosted runner: since fallow 3.20 every import re-walks the + # root tsconfig's project references. - name: Check numeric ranges id: freerange background: true uses: ./.github/actions/run-gate with: { gate: freerange } - # A cold fallow run re-resolves every import against the root tsconfig's project - # references, which costs minutes on a hosted runner; the parse cache skips unchanged files. - - name: Restore fallow cache - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.2.3 + - name: Run Fallow audit + id: fallow-audit + background: true + env: + FALLOW_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + uses: ./.github/actions/run-gate with: - path: .fallow - key: fallow-${{ github.job }}-${{ github.sha }} - restore-keys: fallow-${{ github.job }}- + gate: fallow + args: | + --base + ${{ env.FALLOW_BASE }} - name: Check import-direction DAG # Structured import-direction lint over the resolved graph. See @@ -236,20 +241,6 @@ jobs: uses: ./.github/actions/run-gate with: { gate: mcp-metadata } - - name: Run Fallow audit - env: - FALLOW_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} - uses: ./.github/actions/run-gate - with: - gate: fallow - args: | - --base - ${{ env.FALLOW_BASE }} - - - name: Check for production-unused exports - uses: ./.github/actions/run-gate - with: { gate: production-exports } - # The frozen replay-compat corpus (#1417) claims each entry was published by # a released tag. Only a full-history checkout can re-derive that claim. - name: Verify corpus entries against their released blobs @@ -266,13 +257,13 @@ jobs: uses: ./.github/actions/run-gate with: { gate: daemon-wire-compat } - # Main pushes refresh the cache PR runs restore; PR runs only read it. - - name: Save fallow cache - if: ${{ github.event_name == 'push' }} - uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.2.3 - with: - path: .fallow - key: fallow-${{ github.job }}-${{ github.sha }} + # Shares fallow's `.fallow` cache directory with the audit, so it starts once the audit ends. + - name: Wait for the Fallow audit + wait: fallow-audit + + - name: Check for production-unused exports + uses: ./.github/actions/run-gate + with: { gate: production-exports } - name: Wait for numeric ranges wait: freerange @@ -292,14 +283,6 @@ jobs: - name: Setup toolchain uses: ./.github/actions/setup-node-pnpm - # The CRAP steps score through `fallow health`; see Repo Guards for why its cache matters. - - name: Restore fallow cache - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.2.3 - with: - path: .fallow - key: fallow-${{ github.job }}-${{ github.sha }} - restore-keys: fallow-${{ github.job }}- - - name: Test changed-line coverage gate uses: ./.github/actions/run-gate with: { gate: coverage-model } @@ -369,13 +352,6 @@ jobs: gate: coverage-crap args: --all - - name: Save fallow cache - if: ${{ !cancelled() && github.event_name == 'push' }} - uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.2.3 - with: - path: .fallow - key: fallow-${{ github.job }}-${{ github.sha }} - integration: name: Integration Tests runs-on: ubuntu-latest diff --git a/.github/workflows/fallow-probe.yml b/.github/workflows/fallow-probe.yml deleted file mode 100644 index dffd7e1a74..0000000000 --- a/.github/workflows/fallow-probe.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Fallow probe (temporary) - -on: - pull_request: - paths: - - '.github/workflows/fallow-probe.yml' - -permissions: - contents: read - -jobs: - warm-from-base: - runs-on: ubuntu-latest - timeout-minutes: 60 - env: - BASE: ${{ github.event.pull_request.base.sha }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - - uses: ./.github/actions/setup-node-pnpm - - name: Build the cache at the base commit (what a main push would save) - run: | - git checkout -q "$BASE" - time pnpm exec fallow dead-code --summary || true - time pnpm check:production-exports || true - du -sh .fallow - git checkout -q "$GITHUB_SHA" - - name: audit on head, cache from base - run: time pnpm gate fallow --base "$BASE" || true - - name: production-exports on head, cache from base - run: time pnpm gate production-exports || true - - name: health on head, cache from base - run: time pnpm exec fallow health --summary --performance || true - - name: audit again (fully warm) - run: time pnpm gate fallow --base "$BASE" || true - - parallel-syntax: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - id: slow - run: sleep 20; echo "out=slow" >> "$GITHUB_OUTPUT" - background: true - - parallel: - - run: sleep 10; echo a - - run: sleep 10; echo b - - wait: slow - - run: echo "slow said ${{ steps.slow.outputs.out }}" From 249e6ed3dec33ba08d5eb94b61134d1d6a3ab34c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 18:34:59 +0200 Subject: [PATCH 07/11] ci: run numeric ranges in sequence so only the fallow audit shares the runner --- .github/workflows/ci.yml | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbe0ff2a2d..4d35afaa3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,15 +148,9 @@ jobs: - name: Setup toolchain uses: ./.github/actions/setup-node-pnpm - # The two slowest gates run in the background beside the light guards below. A cold fallow - # run costs several minutes on a hosted runner: since fallow 3.20 every import re-walks the - # root tsconfig's project references. - - name: Check numeric ranges - id: freerange - background: true - uses: ./.github/actions/run-gate - with: { gate: freerange } - + # The fallow audit runs in the background beside the guards below. A cold fallow run costs + # several minutes on a hosted runner: since fallow 3.20 every import re-walks the root + # tsconfig's project references. - name: Run Fallow audit id: fallow-audit background: true @@ -257,6 +251,10 @@ jobs: uses: ./.github/actions/run-gate with: { gate: daemon-wire-compat } + - name: Check numeric ranges + uses: ./.github/actions/run-gate + with: { gate: freerange } + # Shares fallow's `.fallow` cache directory with the audit, so it starts once the audit ends. - name: Wait for the Fallow audit wait: fallow-audit @@ -265,9 +263,6 @@ jobs: uses: ./.github/actions/run-gate with: { gate: production-exports } - - name: Wait for numeric ranges - wait: freerange - # Runs the full unit + provider-integration suites under coverage with # thresholds, so a separate unit-tests job would rerun the same tests. coverage: From 27d5ca6241e8193838465eb6123ba2543acf444f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 18:47:50 +0200 Subject: [PATCH 08/11] ci: cancel a PR's unfinished runs when it closes, and supersede runs in the remaining PR workflows --- .github/workflows/agent-guidance.yml | 5 ++++ .github/workflows/docs.yml | 5 ++++ .github/workflows/pr-cancel-on-close.yml | 35 ++++++++++++++++++++++ .github/workflows/test-app-build-cache.yml | 5 ++++ 4 files changed, 50 insertions(+) create mode 100644 .github/workflows/pr-cancel-on-close.yml diff --git a/.github/workflows/agent-guidance.yml b/.github/workflows/agent-guidance.yml index 7a6053a975..5a5b056331 100644 --- a/.github/workflows/agent-guidance.yml +++ b/.github/workflows/agent-guidance.yml @@ -29,6 +29,11 @@ on: permissions: contents: read +concurrency: + group: >- + ci-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: true + jobs: contract: name: Ownership and context budgets diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f8e7882058..9050cbe633 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -42,6 +42,11 @@ on: permissions: contents: read +concurrency: + group: >- + ci-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: true + jobs: check: name: Prose, internal links, and heading anchors diff --git a/.github/workflows/pr-cancel-on-close.yml b/.github/workflows/pr-cancel-on-close.yml new file mode 100644 index 0000000000..559e2e6a89 --- /dev/null +++ b/.github/workflows/pr-cancel-on-close.yml @@ -0,0 +1,35 @@ +name: Cancel runs of closed PRs + +# A merged or closed PR gates nothing, so its queued and running runs only hold runners. +# `pull_request_target` runs the base branch's workflow with a token that can cancel runs of +# fork PRs too; it never checks out the PR's code. +on: + pull_request_target: + types: + - closed + +permissions: + actions: write + +jobs: + cancel: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # pr-preview-cleanup.yml runs on the same close event and must finish. + - name: Cancel the PR's unfinished runs + env: + GH_TOKEN: ${{ github.token }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + run: | + gh api --paginate -X GET "repos/$GITHUB_REPOSITORY/actions/runs" \ + -f branch="$HEAD_REF" -f event=pull_request -f per_page=100 \ + --jq ".workflow_runs[] + | select(.status != \"completed\") + | select(.head_repository.full_name == \"$HEAD_REPO\") + | select(.path != \".github/workflows/pr-preview-cleanup.yml\") + | .id" | + while read -r id; do + gh run cancel "$id" --repo "$GITHUB_REPOSITORY" || true + done diff --git a/.github/workflows/test-app-build-cache.yml b/.github/workflows/test-app-build-cache.yml index 67ed05aae9..68cc873932 100644 --- a/.github/workflows/test-app-build-cache.yml +++ b/.github/workflows/test-app-build-cache.yml @@ -26,6 +26,11 @@ permissions: # Look up whether this fingerprint's artifact already exists across runs. actions: read +concurrency: + group: >- + ci-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: true + jobs: fingerprint: name: Resolve native fingerprint From a096d79e76adb39942cce2b9cb1bb888b6d40a69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 18:49:01 +0200 Subject: [PATCH 09/11] ci: cancel closed-PR runs from a pull_request trigger instead of pull_request_target --- .github/workflows/pr-cancel-on-close.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr-cancel-on-close.yml b/.github/workflows/pr-cancel-on-close.yml index 559e2e6a89..a192d3ea88 100644 --- a/.github/workflows/pr-cancel-on-close.yml +++ b/.github/workflows/pr-cancel-on-close.yml @@ -1,10 +1,8 @@ name: Cancel runs of closed PRs # A merged or closed PR gates nothing, so its queued and running runs only hold runners. -# `pull_request_target` runs the base branch's workflow with a token that can cancel runs of -# fork PRs too; it never checks out the PR's code. on: - pull_request_target: + pull_request: types: - closed @@ -13,6 +11,8 @@ permissions: jobs: cancel: + # A fork PR's token is read-only, so it cannot cancel runs. + if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -21,13 +21,14 @@ jobs: env: GH_TOKEN: ${{ github.token }} HEAD_REF: ${{ github.event.pull_request.head.ref }} - HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + RUN_ID: ${{ github.run_id }} run: | gh api --paginate -X GET "repos/$GITHUB_REPOSITORY/actions/runs" \ -f branch="$HEAD_REF" -f event=pull_request -f per_page=100 \ --jq ".workflow_runs[] | select(.status != \"completed\") - | select(.head_repository.full_name == \"$HEAD_REPO\") + | select(.head_repository.full_name == \"$GITHUB_REPOSITORY\") + | select(.id != $RUN_ID) | select(.path != \".github/workflows/pr-preview-cleanup.yml\") | .id" | while read -r id; do From ca79480acce21ecc1fe350d9efcb5feaa0d96b67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 19:37:13 +0200 Subject: [PATCH 10/11] ci: keep test-app build-cache runs uncancelled, and fail on a refused cancel of a running run --- .github/workflows/pr-cancel-on-close.yml | 16 +++++++++++----- .github/workflows/test-app-build-cache.yml | 5 ----- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/pr-cancel-on-close.yml b/.github/workflows/pr-cancel-on-close.yml index a192d3ea88..acf333f1ba 100644 --- a/.github/workflows/pr-cancel-on-close.yml +++ b/.github/workflows/pr-cancel-on-close.yml @@ -23,14 +23,20 @@ jobs: HEAD_REF: ${{ github.event.pull_request.head.ref }} RUN_ID: ${{ github.run_id }} run: | - gh api --paginate -X GET "repos/$GITHUB_REPOSITORY/actions/runs" \ + ids=$(gh api --paginate -X GET "repos/$GITHUB_REPOSITORY/actions/runs" \ -f branch="$HEAD_REF" -f event=pull_request -f per_page=100 \ --jq ".workflow_runs[] | select(.status != \"completed\") | select(.head_repository.full_name == \"$GITHUB_REPOSITORY\") | select(.id != $RUN_ID) | select(.path != \".github/workflows/pr-preview-cleanup.yml\") - | .id" | - while read -r id; do - gh run cancel "$id" --repo "$GITHUB_REPOSITORY" || true - done + | .id") + refused=() + for id in $ids; do + gh run cancel "$id" --repo "$GITHUB_REPOSITORY" || refused+=("$id") + done + # A run that finished after the listing refuses cancellation; any other refusal is an error. + for id in "${refused[@]}"; do + status=$(gh run view "$id" --repo "$GITHUB_REPOSITORY" --json status --jq .status) + [ "$status" = completed ] || { echo "::error::Could not cancel run $id ($status)"; exit 1; } + done diff --git a/.github/workflows/test-app-build-cache.yml b/.github/workflows/test-app-build-cache.yml index 68cc873932..67ed05aae9 100644 --- a/.github/workflows/test-app-build-cache.yml +++ b/.github/workflows/test-app-build-cache.yml @@ -26,11 +26,6 @@ permissions: # Look up whether this fingerprint's artifact already exists across runs. actions: read -concurrency: - group: >- - ci-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }} - cancel-in-progress: true - jobs: fingerprint: name: Resolve native fingerprint From 01992b32fdbcac142b5011baf899dc8e861dad8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 19:41:18 +0200 Subject: [PATCH 11/11] ci: cancel only runs that belong to the closed PR --- .github/workflows/pr-cancel-on-close.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pr-cancel-on-close.yml b/.github/workflows/pr-cancel-on-close.yml index acf333f1ba..63a527457e 100644 --- a/.github/workflows/pr-cancel-on-close.yml +++ b/.github/workflows/pr-cancel-on-close.yml @@ -21,6 +21,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} RUN_ID: ${{ github.run_id }} run: | ids=$(gh api --paginate -X GET "repos/$GITHUB_REPOSITORY/actions/runs" \ @@ -28,6 +29,7 @@ jobs: --jq ".workflow_runs[] | select(.status != \"completed\") | select(.head_repository.full_name == \"$GITHUB_REPOSITORY\") + | select(any(.pull_requests[]; .number == $PR_NUMBER)) | select(.id != $RUN_ID) | select(.path != \".github/workflows/pr-preview-cleanup.yml\") | .id")