Repository navigation
fix(ci): shard the hosted test suite so a slow runner cannot hit the 60 min limit - #442
Merged
Merged
Conversation
…60 min limit The serial `test` job ran all ~570 bun test files in one Turbo task and took 37-42 min, so slow hosted runners were cancelled at GitHub's 60 min job limit, and Turbo's buffered output could not name the file that hung. CI now runs four `test shard i/4` jobs. scripts/test-shard-plan.ts splits the `bun run test` file set (root tests/ with bunfig preloads, plus packages/db) by the checked-in durations in scripts/test-shard-durations.json, longest first, with a default for new files. scripts/test-shard.ts runs each file in its own `bun test` process from its suite directory, prints a start and a result line per file, stops a file after 12 min and the shard at 30 min or on SIGINT/SIGTERM, and then names the running file, the unstarted files and the slowest files. Every shard first verifies that the four shards cover the files git lists exactly once. The required `test` check is now a fan-in that passes only when every shard succeeded; the prerelease gate also requires each shard by name. Refs HACK-1381 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The exec env-change test asserted an empty stdout but relied on an earlier test file leaking HACK_LOGGER=console into the shared bun process. Run alone (as each test shard now runs files), the logger fell back to clack on hosts without gum, which writes the CoreDNS/Caddy warnings to stdout, so the assertion failed. Set HACK_LOGGER=console in beforeAll and restore it in afterAll so the file passes in any order. Refs HACK-1381 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
roodboi
marked this pull request as ready for review
October 24, 2026 05:49
roodboi
added a commit
that referenced
this pull request
Oct 24, 2026
…le CI test job docs (#476) Follow-ups to #442 (CI sharding, merged as `6e4bcf2b`). Parts (1) and (2) are fixed here. Part (3), the release workflow, is a **proposed diff only**: it is not part of this PR. The reason is in that section below. ## Root cause Since #442, the hosted suite runs in four `test shard i/4` jobs. The `test` job is now only the required fan-in. It needs `test-shard` and runs no Bun. 1. Two docs still said a test "runs in the CI `test` job". 2. `bun run ci:bun-crash-census` defaulted to `--job test`, so it read the fan-in. A failed fan-in log has no Bun output, so the census reported zero crashes for it and gave no warning. `workflowJobBunVersion(ci.yml, "test")` found the fan-in's block, which has no `bun-version`, and `"test shard 1/4"` matched no workflow key at all. The workaround in `.ai/skills/hack-repo-verify/SKILL.md` (`--job "test shard i/4"`) did not work either. The `--job` check refused `/`, so the census exited with `Invalid argument` (reproduced on `origin/next`). 3. `.github/workflows/release-prepare.yml` still runs the serial `bun run turbo:test`. It has no `timeout-minutes`, so GitHub's 360 min default applies, and Turbo buffers the output. ## Fix **(1) Docs.** `docs/security/env-secrets-threat-model.md:99` and `docs/reference/shared-host- lifecycle.md:230` now say the test runs in one of the hosted `test shard i/4` jobs, and that the required `test` check passes only when every shard passes. **(2) Crash census** (`scripts/ci-bun-crash-census.ts`): - **Default jobs:** with no `--job`, the census reads every `test shard i/n` job of a run. A run with no shard job is from before the split, so its `test` job ran the suite and is read instead. The same window can span both kinds of run. - **`--job` values:** `--job` now accepts `/` and a `*` pattern. `--job "test shard 1/4"` and `--job "test shard *"` both work. Other characters are still refused. - **Workflow Bun version:** `workflowJobBunVersion` now matches a job by its `name:` template, read only under `jobs:`. A `${{ ... }}` expression in the template matches any text. So `test shard 2/4` finds the `test-shard` matrix job's pin, and `test` still returns null. - **Warnings:** the report adds a closed `warning:` line when no job matched the selector, or when a selected job shows no Bun setup in its log or workflow block. The same line goes to stderr, and `--json` gets a `warnings` array. The warning contains no log content. - **Docs:** `.ai/skills/hack-repo-verify/SKILL.md` now describes the new default. ## Tests and evidence **Focused tests:** `tests/ci-bun-crash-census.test.ts` (16 pass). New cases: - Default selection on a mixed window: shard jobs are read, the fan-in and `docker-e2e shard` jobs are skipped, and a run from before the split is still read. - Explicit `--job test` on a sharded run warns, and the fan-in log content is not printed. - A selector that matches no job warns. - The name-template lookup, including quoted templates and `on:` keys. - A contract check against the real `.github/workflows/ci.yml`: all four shard names resolve to the `package.json` `packageManager` Bun pin, and `test` resolves to none. - `--job` with `/` and `*` is accepted; `;` and values over 100 characters are refused. **Mutation proof:** each of these 8 mutations makes the suite fail: - the default selector reads `test`; - the name template is ignored; - no warnings are returned; - the `no_bun_setup` warning is dropped; - the old `--job` check is restored; - the workflow version is looked up under the selector name instead of the job's name; - the default job is `test` again; - warnings are not rendered. **Live read-only census** (GitHub API GET requests only; window 2026-10-24 05:00Z to 12:00Z, 17 CI runs): | Version | Selector | Jobs | Result | | --- | --- | ---: | --- | | `origin/next` | default (`test`) | 2 | One is the fan-in with `bun=unknown`; no warning. | | This branch | default | 5 | Every job has a Bun version (`log:1,workflow:4,unknown:0`); no warning. | | This branch | `--job test` | 2 | The fan-in prints a `warning: 1 of 2 selected jobs show no Bun setup ...` line. | | This branch | `--job "test shard 1/4"` | 1 | Runs; this value was refused before. | **Other checks:** - Root `bun run check` passes, including `privacy:check`. - `bun run --cwd packages/cli typecheck` passes with 0 errors. - `bunx ultracite check` on the changed files passes. - Neighbouring tests pass: `shared-host-lifecycle`, `aes-gcm-v1-context-binding`, `bun-crash- loop` and `agent-instruction-source`, which reads the verify skill. - gitleaks 8.30.1 over `origin/next..HEAD` found no leaks. **Full suite:** the hosted CI `test` check (all four shards) at this PR's head. ## (3) release-prepare: proposed diff, not applied **Why it is not applied.** The change cannot be validated without a release dispatch or a push to `main`, and both are outside this unit. `release-prepare.yml` runs only on `push: main` and `workflow_dispatch`. A pull request never runs it, so no hosted check in this PR would exercise the change. Two findings make a run-free change unsafe to claim: - **`next`'s release-prepare cannot pass as written.** On `next`, `turbo:test` is `bun run test:preflight && turbo run test`. The preflight needs the matching native config compiler (`dist/hack-config-compiler`). `release-prepare.yml` installs no Rust toolchain and never runs `bun run build:config-compiler`, so the preflight fails before any test starts. On `main`, `turbo:test` has no preflight. The last `main` release-prepare (`40050242500`) ran 165 files in 30 s. `next` has 579 files, planned at about 42 min on the macOS shard runner. - **No hosted job runs `next`'s full suite on Linux.** The `test shard` jobs run on `blacksmith-6vcpu-macos-15`, and `linux-process-lifetime` runs only 6 files. release-prepare runs on `blacksmith-4vcpu-ubuntu-2404`. So the Linux duration and the Linux pass/fail result of the whole suite are both unmeasured. The change only matters when `next` is merged into `main`. The workflow that runs is the copy on `main`, and `scripts/test-shard.ts` exists only on `next`, so this diff must not be applied to `main` by itself. It applies cleanly to `next` at `6e4bcf2b`: ```diff --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -16,6 +16,9 @@ jobs: semantic-release: runs-on: blacksmith-4vcpu-ubuntu-2404 + # GitHub's default limit is 360 min. The suite's own 90 min deadline stops + # first and names the running file (HACK-1424). + timeout-minutes: 120 env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" steps: @@ -47,8 +50,17 @@ - name: Quality checks (Turbo) run: bun run turbo:check - - name: Tests (Turbo) - run: bun run turbo:test + # test:preflight needs the matching native config compiler (HACK-1424). + - uses: dtolnay/rust-toolchain@1.97.1 + + - name: Prepare matching config compiler + run: bun run build:config-compiler + + # The `bun run test` file set, Bun pin check and compiler preflight, one + # `bun test` process per file with start/PASS/FAIL lines, a 12 min file + # limit and a 90 min deadline that names the running file (HACK-1381). + - name: Tests (per file) + run: bun scripts/test-shard.ts run --shard=1/1 --deadline-seconds=5400 - name: Run semantic-release env: ``` **What stays the same:** - **Gates:** typecheck, privacy check, quality checks, then the tests. `semantic-release` runs only if every earlier step passes. - **The test run still fails when it should.** The runner exits non-zero when any file fails, times out, reports a file count other than 1, or reaches the deadline. - **Exact-head semantics:** one job, one checkout of the pushed SHA, the same `concurrency` group, `cancel-in-progress: false`. - **Outputs:** the same `semantic-release` step with the same token. No new outputs, artifacts or permissions. **What is new:** - `check:bun-version` now runs before the tests. It already runs before every `bun run test` and every CI shard. - The `git ls-files` coverage check: `verify --count=1` on this head reports "579 files in 1 shards cover the full test set exactly once", planned at 42m22s. - Per-file progress lines, a heartbeat every 2 min, a 12 min limit per file, a 90 min limit for the run, and a 120 min limit for the job. **Validated statically:** - actionlint 1.7.12 with the repository config passes on both the current and the proposed file. - `bun scripts/test-shard.ts verify --count=1` and `plan --shard=1/1` succeed on this head. **Not validated:** the run itself on the Linux release runner (compiler build, Linux suite result and duration). That needs one `workflow_dispatch` of `release-prepare.yml` against `next`, or the next-to-main merge. On `next`, a dispatched run would also reach `bunx semantic- release` if the tests pass, so a dry run would need `--dry-run` or a temporary branch filter. I did not dispatch it. ## Gaps - (3) is proposed, not applied. HACK-1424 acceptance allows "explicitly scheduled with a reason", and the reason is above. - The census's live evidence covers a 7-hour window around the #442 merge, not a long history. - A job whose workflow fetch fails is also counted as "no Bun setup" in the warning. This is intended: in both cases the zero is not evidence. Refs HACK-1424 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root cause
The hosted
testjob ran everybun run testfile (568 roottests/files pluspackages/db) in one Turbo task onblacksmith-6vcpu-macos-15. That step alone took about 39 min (next run 39873410893:Tests (Turbo)23:42:18 to 00:21:20). Slower runners reached the 60 min job limit and were cancelled. Turbo buffers task output until the end, so a cancelled run did not show which file was running.Fix
scripts/test-shard-plan.tsfinds the same file set thatbun run testruns: roottests/(withbunfig.tomlroot = "./tests"and its preload) andpackages/db(from its own directory). It splits the files into shards, longest first. Durations come fromscripts/test-shard-durations.json. That table was first built from the per-test times in the hosted log above. A file with no recorded duration is planned at 5 s, and each file adds 0.3 s for process start. The split is deterministic.scripts/test-shard.ts run --shard=i/4:check:bun-versionandtest:preflightfirst, asbun run testdoes;bun test ./<file>in its own process, from the file's suite directory, so the bunfig preload and the db path aliases apply;startandPASS/FAILlines with the file's duration and the shard's elapsed time, and a heartbeat every 2 min while a file runs;test-shard-durations-<i>artifact.update-durationsmerges those artifacts back into the table.verify --count=4: the files found on disk must equal the filesgit ls-fileslists. The union of all shards must equal that set, with no file in two shards. The set of workspaces with atestscript, and the bunfig test root, must match the known suites..github/workflows/ci.yml:test-shardmatrix (test shard i/4,fail-fast: false,timeout-minutes: 40). It keeps the existing runner, Bun 1.4.2, frozen install, Rust 1.97.1 and the compiler build on every shard. Typecheck, privacy, lint, the CLI build and the release smoke run on shard 1 only.testis now a fan-in job (needs: test-shard,if: always()). It passes only when every shard succeeded, so the required check nametestdoes not change. No admin change to the ruleset is needed.scripts/prerelease-plan.tsCI_JOBSnow also requires eachtest shard i/4job, the same way it requires thedocker-e2eshards.Tests
tests/test-shard.test.ts(37 tests) covers:git ls-files;bun testchild processes: exit status, suite cwd, stopping a hung file, and not waiting for a leftover process that holds stderr open;Mutation proofs (each mutation was applied, the focused file was run, then the file was restored). Each one made at least one test fail:
testfan-in withoutalways()CLI mutation: when the partition drops a file,
bun scripts/test-shard.ts verify --count=4exits 2 withnot run by any shard (1): tests/zig-aarch64-musl-linker.test.ts. Restored, it exits 0 with all files in 4 shards (571 files when first written, 573 at503dc18d, 576 at57736870; planned about 10m25s to 10m32s per shard).Evidence
mise x bun@1.4.2), Rust 1.97.1.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1):bun run check(includesprivacy:check) passed;bun run --cwd packages/cli typecheckpassed;test-shard,prerelease-plan,e2e-shard,native-plan-cli-wiring,ci-bun-crash-censusandagent-instruction-source(135 tests);origin/next..HEADfound no leaks.testcheck passes only when all 4 shards pass.Hosted runs
Times are the
Tests (shard)step; the whole job adds about 1 to 1.5 min of setup. Every shard job hastimeout-minutes: 40.a33511f4503dc18d57736870Whole shard jobs took 10m34s to 13m54s across the 3 runs, well under the 40 min timeout. For comparison,
next's serialtestjob (39916341281,7d9c89f8) took 40m17s, withTests (Turbo)at 39m21s, and run 39783802503 was cancelled at 60m25s.Same file set as the serial job: the shard logs of run 39977193863 list 573 unique PASS files.
next's serial log (39916341281) lists 572 files; the extra one is the newtests/test-shard.test.ts. Unique skipped tests are the same 157 names on both. Passed tests are 8057 onnextand 8094 on the PR; the 37 extra are the newtest-shardtests. Running each file in its own process did not skip anything new.Why run 39888691204 was red
Shard 1 failed one test in
tests/project-exec-command.test.ts: "exec warns value-free on stderr only after the service env changed since start". The clack CoreDNS/Caddy warnings went to stdout instead of stderr.This was a test that depended on file order, not a sharding bug and not a known flake. In the old single
bun testprocess, an earlier file had already setHACK_LOGGER=console. Run on its own, on a host withoutgum, the logger falls back to clack, which writes to stdout. Repro onorigin/next, running only that file withgumremoved fromPATH: 4 pass, 1 fail, the same test.Fix (
503dc18d,test: pin the console logger in the exec env-change test): the test file setsHACK_LOGGER=consoleinbeforeAlland restores the previous value inafterAll. With the fix the file gives 5 pass on its own; withnext's version of the file put back it gives 4 pass, 1 fail again. Run 39977193863 at503dc18dis green.Round 2 (verifier follow-up)
503dc18d, and 576 after mergingnextat5208ee54(which adds test: fail and contain process-state leaks between test files #405, the global-state-guard preload, and fix(agents): name the permission fix and keep a value-free cause on input access refusals #452).57736870: merge ofnext5208ee54into503dc18d. The merge was clean; no PR file changed.57736870(Bun 1.4.2, private HOME/HACK_HOME/DOCKER_CONFIG,HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1, a bogusDOCKER_HOST):dirover thegit log -p origin/next..HEADexport: no leaks;bun run check(includesprivacy:check): 2 of 2 tasks passed;bun run --cwd packages/cli typecheck: exit 0;test-shard,prerelease-planandproject-exec-command: 99 pass, 0 fail;bun scripts/test-shard.ts verify --count=4: 576 files in 4 shards, each file exactly once, planned about 10m31s per shard. 7 files fromnexthave no recorded duration yet and use the 5 s fallback.57736870: run 40057462690: all 20 checks green, includingtest shard 1/4to4/4and thetestfan-in. The shard logs list 576 unique PASS files and no FAIL line, which matches the local plan. Shard jobs took 10m34s to 12m11s.test(ruleset onnext). The fan-in job keeps that name, so no admin change is needed.Round 3 (re-check)
57736870, andnextis still5208ee54, which is an ancestor of the head. No merge or new commit was needed, so nothing was pushed this round.gh pr checks 442shows all 20 checks green at57736870. TheTests (shard)step times of run 40057462690 match the table (10m12s, 10m39s, 9m49s, 11m29s). Its shard logs list 576 unique PASS files and no FAIL line.bun scripts/test-shard.ts verify --count=4at57736870: 576 files in 4 shards, each exactly once, planned 10m31s to 10m32s per shard.Gaps
bun testprocess. Files no longer share module state or global hooks across files. That is stricter isolation, so a test that depended on another file running first in the same process would now fail. The red run 39888691204 was such a case: a test passed in the shared process only because an earlier file had setHACK_LOGGER=console. It is fixed in503dc18d(see "Why run 39888691204 was red"). The green hosted runs are the evidence for the current tree.scripts/ci-bun-crash-census.tsmatches one exact job name, and its default istest. For runs after this change the Bun logs are in thetest shard i/4jobs, so the census needs--job "test shard i/4"once per shard. This is documented in the verify skill. The script itself is not changed here.Refs HACK-1381
🤖 Generated with Claude Code