Skip to content

ci(test): shard suite into four time-balanced slices #2313

ci(test): shard suite into four time-balanced slices

ci(test): shard suite into four time-balanced slices #2313

Workflow file for this run

name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: ci-${{ github.workflow }}-${{ github.event_name == 'push' && github.sha || github.ref }}
cancel-in-progress: ${{ github.event_name != 'push' }}
jobs:
# oxfmt, oxlint, and typecheck share one runner: one checkout and one
# install instead of three of each. Dummy job names prettier and eslint
# stay for protect-main.
static-analysis:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.14"
- name: Cache dependencies
uses: actions/cache@v4
with:
path: node_modules
# Exact-key-only: a restore-keys prefix of bun- would hydrate
# node_modules from a different lockfile. bun install then has to
# reconcile a stale tree; missing that step leaves wrong deps.
key: bun-${{ hashFiles('bun.lock') }}
- name: Install dependencies
run: bun install --frozen-lockfile
- name: oxfmt
run: bunx oxfmt --check .
- name: oxlint
run: bunx oxlint .
- name: Typecheck
run: bun run typecheck
# Build runs beside the suite instead of before it: tests import ./src
# directly and never read ./dist, so serializing build ahead of test put
# build time on the critical path for no dependency reason.
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.14"
- name: Cache dependencies
uses: actions/cache@v4
with:
path: node_modules
key: bun-${{ hashFiles('bun.lock') }}
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build
run: bun run build
# The suite is sharded so the slowest slice, not the whole suite, sets the
# wall clock. Four time-balanced shards split the same
# ./src ./tests ./evals ./scripts union via bun's --shard=k/4, balanced by
# the checked-in per-file durations in scripts/ci-timings.json (--timings).
# Every shard still goes through check:projects-dir-guard: the guard
# forwards the path union plus the shard flags to the suite it wraps, so the
# gate covers the same tests as before, all of them sandboxed.
#
# Timings refresh policy: regenerate scripts/ci-timings.json by running the
# full union locally with --update-timings (same seeded flags as `test`):
# bun run check:projects-dir-guard ./src ./tests ./evals ./scripts \
# --timings=./scripts/ci-timings.json --update-timings
# Regen when the slowest shard's Test step skews more than ~20% above a
# quarter of the one-process suite time (shards drifting apart means the
# timings no longer describe the suite), or proactively whenever slow files
# land. A scheduled refresh artifact is a future option, not current setup.
test:
runs-on: ubuntu-latest
strategy:
# A red shard must not cancel the others; all results are the signal.
fail-fast: false
matrix:
shard: ["1/4", "2/4", "3/4", "4/4"]
name: test (${{ matrix.shard }})
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.14"
# The runner image has no ripgrep, so the grep plugin silently exercised
# its fallback walker and left the ripgrep path untested.
- name: Install ripgrep
run: sudo apt-get install -y ripgrep
- name: Cache dependencies
uses: actions/cache@v4
with:
path: node_modules
key: bun-${{ hashFiles('bun.lock') }}
- name: Install dependencies
run: bun install --frozen-lockfile
# The same script the local `bun run check` gate runs, with the full
# path union plus the shard's --shard/--timings flags forwarded through
# the guard to the suite. The guard routes a filtered run through
# test:paths, which carries the same seeded flags as the `test` script;
# bun test filters are additive, so appending filters to `bun run test`
# could not narrow it. --shard splits by file (balanced by --timings),
# so every shard covers the same union and the four shards together
# cover the whole suite. Randomized order catches tests that only pass
# in the default file order (shared module-level state, an unrestored
# global mock, a leaked env var).
# The seed stays 424242 in every shard rather than varying per shard:
# the shards already run disjoint file sets, and a fixed seed keeps
# any failure reproducible locally with the same
# `bun run test:paths <paths> --shard=k/4
# --timings=./scripts/ci-timings.json`.
- name: Test
run: bun run check:projects-dir-guard ./src ./tests ./evals ./scripts --shard=${{ matrix.shard }} --timings=./scripts/ci-timings.json
# Cross-shard pollution detector: the shards above split the path union,
# but the union is not the isolation domain — a mock.module leak across
# files fails in the one-process suite yet passes when the files land in
# different shards (CL-6967). This job reruns the whole union in one
# process with no filters, exactly like the local `bun run check` gate.
# Non-blocking (continue-on-error) so a slow or flaky full run cannot hold
# the gate; a real pollution failure still shows up red for triage.
test-one-process:
name: test (one-process pollution detector)
continue-on-error: true
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.14"
- name: Install ripgrep
run: sudo apt-get install -y ripgrep
- name: Cache dependencies
uses: actions/cache@v4
with:
path: node_modules
key: bun-${{ hashFiles('bun.lock') }}
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Test
run: bun run check:projects-dir-guard
# protect-main still requires the pre-restructure check names. These jobs
# exist only to publish those contexts after the real work succeeds.
prettier:
needs: static-analysis
runs-on: ubuntu-latest
steps:
- run: "true"
eslint:
needs: static-analysis
runs-on: ubuntu-latest
steps:
- run: "true"
typecheck:
needs: static-analysis
runs-on: ubuntu-latest
steps:
- run: "true"
build-and-test:
needs: [build, test]
runs-on: ubuntu-latest
steps:
- run: "true"