Skip to content

ci: shard the gauntlet across 5 runners (GH #400) - #403

Merged
wasikarn merged 3 commits into
developfrom
claude/ci-shard-gauntlet-400
Oct 3, 2026
Merged

wasikarn merged 3 commits into
developfrom
claude/ci-shard-gauntlet-400

Conversation

@wasikarn

@wasikarn wasikarn commented Oct 3, 2026

Copy link
Copy Markdown
Owner

Closes #400.

What

  • scripts/run-gauntlet.sh: GAUNTLET_SHARD=i/N runs 1/N of the test files. hook_test_files() splits them by greedy bin-packing on byte size (largest first, each file to the lightest shard, lowest index on a tie), then prints the shard in glob order. Validate and lint run only in shard 1 (others print SKIP). Unset = today's behaviour. The var is read into a plain shard and unset, so test files never see it. A malformed value (3, 0/2, 3/2, a/2, ...) exits 2 before anything runs.
  • .github/workflows/validate.yml: the old gauntlet job becomes a 5-way matrix gauntlet shard N (fail-fast: false, same steps, GAUNTLET_SHARD: ${{ matrix.shard }}/${{ strategy.job-total }}). A new gauntlet (run-gauntlet.sh) job (needs the matrix, if: always()) fails unless needs.gauntlet-shard.result == success, so a failed, cancelled or skipped shard turns it red. Its name is the required context, so branch protection is unchanged. concurrency cancels a superseded run only for pull_request; each push to develop has its own group (the sha).
  • CI stays serial inside a shard (GAUNTLET_JOBS still defaults to 1 under CI), one runner per shard, so the 8 s timing rows never share a CPU.
  • Every shard still installs claude code and trash-cli: test-cost-ledger-module.sh needs claude and SKIPs without it, and the tests use trash.

Serial timings (this machine, one file at a time, load avg ~5.6)

Total 374 s over 54 files. Heaviest:

s bytes file
107.5 205502 tests/hooks/test-gates.sh
87.0 64184 tests/hooks/test-subagent-git-guard.sh
25.2 53984 tests/hooks/test-session-stop.sh
21.0 29089 tests/skills/harness-audit/test-harness-audit.sh
18.5 29647 tests/hooks/test-cost-tracker.sh
11.8 13084 tests/hooks/test-memory-audit-commit.sh
11.1 15146 tests/evals/test-eval-cases.sh
10.3 36502 tests/skills/test-cost-report.sh
10.2 15288 tests/scripts/test-eval-default-enabled.sh
6.3 2348 tests/hooks/test-gate-canary.sh
(44 more) each under 6 s, 63 s together

Measured seconds per shard under the byte-size split (seconds/files):

N shards slowest
3 134/14, 67/21, 173/19 173
4 108/4, 48/16, 145/17, 73/17 145
5 107/1, 32/13, 105/13, 66/13, 63/14 107
6 107/1, 24/11, 117/10, 48/10, 23/11, 54/11 117

N=5: test-gates.sh alone sets the floor (107 s), so more shards buy nothing. Byte size is a rough proxy (test_memory_lint.py is 65 KB and runs in 0.5 s), but it needs no timing table to keep current.

Tests

  • New tests/scripts/test-gauntlet-shard.sh (written first, red on the old script: check_shard() not found): unset lists every test file on disk (found by find, not the globs); for N=1..6 the shards partition the files exactly, none is empty, each keeps glob order, and test-gates.sh / test-subagent-git-guard.sh are on different shards for N>=2; check_shard accepts/rejects a table of values.
  • Mutation check: 8 of 9 mutants killed (b==i->b!=i, drop the sum update, drop the glob reorder, skip shard 1 in the min search, -le->-lt, drop the lower bound, accept junk, ignore the shard). The survivor is <-><= in the tie pick, which changes which shard gets a file but keeps a valid deterministic partition. That is equivalent behaviour, not a gap.
  • test-run-gauntlet-wiring.sh, test-gauntlet-failed-summary.sh, test-gauntlet-log-preservation.sh, test-gauntlet-git-env-isolation.sh pass unchanged. GAUNTLET_SHARD=2/5 local run prints SKIP for validate/lint and passes. audit.sh 0 CRIT; full gauntlet green with GAUNTLET_JOBS=2.

CI wall time

Before: last 10 successful PR runs took 217-308 s (median ~302 s). After: see the comment on this PR.

GAUNTLET_SHARD=i/N splits hook_test_files() by greedy byte-size
bin-packing (largest first) so test-gates.sh and
test-subagent-git-guard.sh land on different runners; validate and lint
run only in shard 1. Unset keeps today's behaviour. The required
'gauntlet (run-gauntlet.sh)' check becomes an if: always() aggregator
that fails unless every shard succeeded. PR runs cancel superseded ones;
pushes to develop never cancel.
@wasikarn

wasikarn commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

CI results

Run 37134095322 (first green run, from gh run view):

job s
gauntlet shard 1 (validate + lint + tests) 124
gauntlet shard 2 41
gauntlet shard 3 (test-gates.sh) 123
gauntlet shard 4 48
gauntlet shard 5 53
gauntlet (run-gauntlet.sh) aggregator 2
claude plugin validate 12
harness-audit 8
run wall time 137

Before: median ~302 s wall (last 10 successful PR runs: 217-308 s). After: 137 s, and 138 s on the green re-run 37134676789. Runner time goes up from about 5.3 min to about 6.9 min per run (setup is repeated on 5 shards). Wall time is what a merge waits on.

Red proof: run 37134386190 had a throwaway commit making tests/scripts/test-gh-comment-bodies.sh exit 1 only when CI is set (so the local pre-push stayed green). Shard 3 failed with failing: tests/scripts/test-gh-comment-bodies.sh, the other shards passed, and gauntlet (run-gauntlet.sh) went red. Reverted in 90f5c64; the re-run is green.

@wasikarn
wasikarn merged commit 085f636 into develop Oct 3, 2026
8 checks passed
@wasikarn
wasikarn deleted the claude/ci-shard-gauntlet-400 branch October 3, 2026 15:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ci: shard the gauntlet across runners so a PR merges in about 2 min of CI, not 5

1 participant