SFEP-0011
CI Test-Speed Plan
- Status
- Accepted
- Type
- tooling
- Created
- Updated
- Author
- agent:compiler-architect
- Tracking
- #1012, #843
CI Test-Speed Plan: cutting PR feedback time
Status: Phase 1 (CI sharding + shard-cover lint) implemented in PR #1012; Levers 2 (per-test binary cache) and 3 (scope trim) remain proposed. Author: compiler-architect Reconciles with: #839/#843 (test-infra epic, Phase 4), #513 (Makefile slim-down), #339 (build perf), #940 (multi-file runner forking), #1011 (macOS flakes under parallel-build contention).
1. Goal
Cut PR CI wall time — currently Linux ~24 min / macOS ~47 min — by
attacking the test suite, which is ~90% of wall time on a warm build
cache, without losing any total coverage and without requiring the
post-1.0 routine/spawn runtime that Phase 4 (#843) is blocked on.
Target after Phase 1: macOS 47 min → ~14-16 min, Linux 24 min → ~9-10 min.
2. Current state (measured)
Run 26894545759 (warm content-addressed module cache):
| Phase | Linux | macOS |
|---|---|---|
make rebuild (selfhost, warm cache) |
1m32s | 3m15s |
make test |
21m47s | 42m22s |
| fmt + canary + cross + package + upload | ~75s | ~60s |
The suite is the pain; macOS is ~2x slower per-test than Linux.
How the suite runs today
- PR
ci.yml(.github/workflows/ci.yml:40-126): one matrix leg per OS; thesailfin-buildcomposite action (.github/actions/sailfin-build/action.yml:121-125) callsmake test. make test(Makefile:153-166): one process —$(NATIVE_BIN) test compiler/tests/unit compiler/tests/integration compiler/tests/e2e capsules— thenmake test-e2e-sh(Makefile:268-296, the 94 legacytest_*.shscripts).- The unified runner
handle_test_command(compiler/src/cli_commands.sfn:875) discovers all_test.sfnfiles, then for >1 file takes the multi-file subprocess path (cli_commands.sfn:1071-1175): a sequentialloop(cli_commands.sfn:1144-1170) that forks one<self> test <file>child (_run_test_child,cli_commands.sfn:840-853) per file. Each child compiles the test through LLVM lowering + a clang link (_clang_link_test_cmd_with_deps,cli_commands.sfn:140-193) and runs the binary. - The runtime capsule is warmed once per invocation into a shared
objdir and shared via
SAILFIN_TEST_RUNTIME_OBJDIR(cli_commands.sfn:1121-1141) — so the per-test cost is lower+link+run of the test itself, not the runtime. - No test-level parallelism (the loop is serial) and no per-test binary cache (every child re-lowers + re-links + re-runs every run).
File counts (the shardable surface)
- 118 unit, 30 integration, 4 e2e
_test.sfn; 23 capsule_test.sfn; 94 legacy e2etest_*.sh. ~1,876testblocks total.
Cache plumbing (the seam sharding must respect)
- Content-addressed module IR cache
build/cache/keyed bycache_key_for(compiler/src/build_cache.sfn): hashes source content- sorted dep-manifest hashes + compiler version + canonicalized flags
(
build_cache.sfn:17-29, 508-522). No paths/mtimes — reusable across runners.
- sorted dep-manifest hashes + compiler version + canonicalized flags
(
- Primed by
build-quality.ymlon push:main (build-quality.yml:238-250), restored in the composite action (action.yml:84-91) with prefixrestore-keys. So with warm cache the build is ~free; the cost is the test compile+link+run, which is not cached.
Why Phase 4 (#843) is blocked but we are not
Phase 4.1 (02-phases.md:61-75) is in-process parallelism (sfn test --jobs N running N routines), gated on the structured-concurrency
runtime that doesn’t exist. CI-level sharding (separate OS processes /
CI jobs) needs none of that — GitHub gives us the parallelism for free
across matrix legs. This plan pulls the value of Phase 4 forward via CI
fan-out + an OS-process xargs -P seam, explicitly not via the
blocked in-process runtime.
3. Constraints (non-negotiable)
- Self-hosting invariant. Compiler fixes land in
compiler/src/*.sfn; the driver is pure orchestration, no fixups. - Memory cap. Every compiler invocation on Linux/WSL prefixed
ulimit -v 8388608. Parallelism multiplies memory pressure — the shard count must respect the per-job RAM budget (detect_build_jobs.shrationale: ~2 GB/worst-module; macOS runner ~7 GB ceiling). - Formatting gate.
sfn fmt --checkmust pass on touched.sfn. - Determinism. Any test-binary caching must not weaken the
fixed-point /
--check-determinismgates (ci.yml:277-330,build-quality.yml:180-236). Those gate the compiler build, not test binaries — but a test-binary cache must invalidate correctly so a changed dep never reuses a stale binary. - #1011 — scratch isolation. macOS flakes when parallel module builds
contend on shared cache/staging. Any new parallelism (sharded jobs,
xargs -P) must give each worker an isolated scratch root viaSAILFIN_TEST_SCRATCH/SAILFIN_TEST_RUNTIME_OBJDIR(_test_scratch_root,cli_commands.sfn:218); see §7.
4. The three levers
Lever 1 — Shard the suite across parallel CI jobs (highest ROI, lowest risk)
Idea. Fan the ci.yml matrix from 2 legs (one per OS) to 2 x N
legs: each leg builds the compiler once, then runs only its shard of the
test surface. GitHub runs them concurrently, so wall time ≈ build +
(suite / N) + fixed overhead.
Where it lives: CI-config (ci.yml) + a thin path-selection seam.
The runner already accepts multiple positional suite paths
(handle_test_command, cli_commands.sfn:919-937) and multiple
_test.sfn files, so no compiler change is required for a coarse
suite-level shard.
Shard map (coarse, Phase 1 — by existing suite, balanced by file count):
| Shard | Contents | ~files |
|---|---|---|
unit-a |
first half of compiler/tests/unit |
~59 |
unit-b |
second half of compiler/tests/unit |
~59 |
int-e2e-caps |
integration + e2e + capsules |
~57 |
e2e-sh |
the 94 legacy test_*.sh (make test-e2e-sh) |
94 scripts |
Four shards per OS. macOS critical path drops from ~42 min to roughly
max(shard) ≈ 42/3.5 ≈ ~12 min for the .sfn shards (the .sh shard
runs in parallel and is independent). Unit is split in half because it is
the largest single suite; the runner has no built-in “half a directory”
selector, so we pass explicit file lists (see seam below).
The build-cost-per-shard problem and its fix. Each shard leg runs
make rebuild first (action.yml:104-113), so naively every shard pays
the 1.5-3 min build. Two options:
- 1a (Phase 1, simplest): accept the rebuild per shard. With the warm
content-addressed cache restored (
action.yml:84-91) the build is 1m32s (Linux) / 3m15s (macOS). Total added build cost =(N-1) x buildof parallel runner-minutes, but wall time is unchanged because shards run concurrently. The only cost is runner-minute consumption (4x build instead of 1x). Acceptable for Phase 1; ship it. - 1b (Phase 2, optimization): split the build into its own job that
uploads
build/bin/sfn+build/compiler/import-context+build/cacheas a workflow artifact (oractions/cachewith a per-run key), and have each shardneeds:that job and download instead of rebuilding. Saves the redundant build runner-minutes and removes build-time variance from the shard critical path. This is the right end-state and aligns with #513 (sfn owning build/check) — the build job becomes the singlesfn build -p compilerproducer.
Seam for “half a directory”. Two implementation choices, in increasing compiler involvement:
- Seam A (CI-only, zero compiler change): the shard’s CI step does
find compiler/tests/unit -name '*_test.sfn' | sort | awk 'NR%2==0'(orsplit) and passes the explicit file list as positionals tosailfin test. The runner already handles a positional file list. A thinmake test-shard SHARD=unit-atarget (or inline inci.yml) encapsulates the selection. Recommended for Phase 1. Owner: CI. - Seam B (compiler, deferred): add
sailfin test --shard I/N <paths>that does deterministic file-count-balanced partitioning internally (hash filename → bucket, or stable sort + stride). Cleaner, keeps shard logic in one tested place, and is reusable bymake/nightly. This is a small, safe runner addition (handle_test_commandflag parse + partition over the already-collectedtest_fileslist,cli_commands.sfn:978-995). Defer to Phase 2 — Seam A unblocks immediately; Seam B is the durable home and should land before the shard count grows past ~4.
Coverage preservation. The union of shards = the current
make test surface, file-for-file. A ci.yml “shard-cover” lint step
(or a make test-shard-cover that asserts sort -u of all shard file
lists == find ... -name '*_test.sfn' | sort) guarantees no file is
dropped or double-counted as shards are rebalanced. This guard is
mandatory — it’s the only thing standing between “rebalance the shard
map” and “silently stop running 12 tests.”
Risk: runner-minute cost (1a); rebalancing drift (mitigated by the cover lint); macOS contention if a single runner ever hosts >1 shard (it won’t — each shard is its own runner). Low overall.
Expected wall delta: macOS PR critical path ~47 → ~16-18 min (build 3m + ~12-14 min worst shard + overhead). Linux ~24 → ~10-11 min.
Lever 2 — Per-test binary cache (the fundamental incremental fix)
Idea. Content-address each test’s compiled native binary on (test source hash + transitive dep hashes + compiler version + clang flags),
mirroring cache_key_for. On a cache hit, skip LLVM lowering + clang
link and just run the cached binary (or skip entirely if we also
cache the pass/fail result — see below). On an incremental PR that
touches 3 files, only the tests whose transitive dep set changed re-link;
the rest hit.
Where it lives: compiler runner (compiler/src/cli_commands.sfn),
reusing build_cache.sfn primitives. This is runner-internals work,
not CI-config.
Cache key. Reuse the existing content-hash machinery in
build_cache.sfn (content_hash at :529, dep-manifest sorting at
:443, canonicalize_flags at :508). The per-test key is:
sha256( content_hash(test.sfn) || sorted(content_hash(each transitive dep .sfn / .sfn-asm)) || compiler_version || canonical(clang_flags) // "-O0", runtime objdir contract, target triple || build_cache_schema_version())The transitive dep set is exactly what the per-group resolver already
computes (TestGroup.native_texts / dep_ll_paths,
cli_commands.sfn:1189-1211) — the dep .ll/.sfn-asm inputs to the
link are enumerable at link time, so the key has access to every input
hash. Correctness hinge: a changed dep changes its content hash,
changes the key, misses the cache. This is the same invariant
cache_key_for already upholds for the module IR cache; we are not
inventing a new correctness model, just a second consumer of it.
Two cache granularities (ship the first, consider the second):
- 2a — cached binary (conservative, recommended first): store the
linked
testexecutable keyed as above underbuild/cache/test-bin/v1/<key>. On hit, skip lower+link, still run the binary. Saves the LLVM-lower + clang-link cost (the dominant cost — clang link of a test + runtime objects is the expensive step), keeps the run (so flaky-at-runtime tests still surface, and we never trust a cached result). Lowest correctness risk. - 2b — cached result (aggressive, gate behind a flag): also store the
exit code / pass-fail, and skip the run on hit. Faster but trusts that
a test with identical inputs is deterministic. Only enable on
PR-shard runs, never on the nightly full
make checkor the determinism gates. Off by default;SAILFIN_TEST_RESULT_CACHE=1.
Invalidation & safety:
- Keyed on content, so any source/dep/compiler/flag change misses — never a false hit. Same model as the module cache.
- LRU-evicted by GitHub’s cache; in-tree it lives under
build/cache/(already git-ignored, already the cache root). - Must not touch the determinism gates. Those run
sfn build --no-cache/--check-determinismagainst the compiler build (ci.yml:305-311,build-quality.yml:174-213) — a separate code path fromhandle_test_command. The test-binary cache is read only by the runner. Add an explicit--no-test-cacheflag (and havemake check/ nightly pass it) so the full-suite gate always cold-builds test binaries and the cache can never mask a test-compile regression. - #1011 interaction: the cache write must be atomic
(write-to-temp-in-scratch + rename into
build/cache/test-bin/) so two shards racing on the same key don’t half-write. The existing hash-tmp pattern (build_cache.sfn:417, unique-per-worker suffix) is the precedent; reuse it. Reads are lock-free (content-addressed: a present file under<key>is correct or absent).
CI plumbing. Add build/cache is already restored
(action.yml:84-91); extend the restore/save to include the
test-bin/v1 subtree (it’s under the same build/cache path, so the
existing cache step covers it — but the save currently only happens
in build-quality.yml on push:main, which runs no tests, so it never
populates test-bin). Two options:
- Add a
actions/cache/saveofbuild/cache/test-binat the end of each PR shard (per-OS, per-shard key) so the next PR push warms from the prior one. Scoped to branch + base-branch per GitHub cache rules, so a PR’s second push reuses its first push’s test binaries — the incremental-PR win. - Optionally have a nightly/push:main job populate a baseline
test-bincache (cold, full suite) that PRs restore from, so even a PR’s first push gets hits for unchanged tests. Best ROI but more plumbing; defer to Phase 3.
Risk: medium. Correctness rests on the dep-hash set being complete
(miss a dep → false hit → stale test passes). Mitigation: derive the dep
list from the same resolver output the link already consumes (no
separate, drift-prone enumeration), and gate the full suite with
--no-test-cache so any escaped staleness is caught nightly/at-merge.
Start with 2a (still runs the binary) to bound the blast radius.
Expected wall delta: depends on PR churn. A typical PR touching a
handful of compiler/src files invalidates the tests transitively
depending on them but hits the rest — on incremental pushes the suite
can drop 50-80% when most tests are unchanged. Stacks multiplicatively
with Lever 1 (each shard caches its own subset).
Lever 3 — Trim PR scope; full suite to nightly/merge
Idea. PRs run a fast smoke subset (especially on macOS, the long pole); the full suite gates on push:main and nightly.
Where it lives: CI-config + a tag/selection seam in the runner.
Defining “smoke”. Three candidate selectors, in order of preference:
- 3a — tag-based (recommended). The runner already supports
--tag <value>filtering (cli_commands.sfn:904-910,set_test_filters). Tag a curated set@tag("smoke")covering: the effect-gate / exit-code regression (#615/#807, already a discreteci.ymlstep), the cross-module ABI tests (#633), the canary/lowering paths, one test per major feature area. PR macOS runssailfin test --tag smoke; Linux runs the full sharded suite (Linux is cheap enough to keep full coverage on every PR). Owner: needs someone to curate and apply the tag across ~176 files — a one-time content pass, plus a CODEOWNERS-style rule that new feature tests get a smoke tag where appropriate. - 3b — path/changed-file selection. Run only tests in suites touched
by the PR diff (e.g. a PR touching only
compiler/src/llvm/**runs unit+integration but skips capsules). Riskier (cross-cutting changes under-test) and needs a diff→suite mapping; defer. - 3c — keep e2e-sh and capsules off macOS PRs. The 94
.shscripts and capsule tests run on Linux PRs + nightly macOS only. Cheap, safe for the long pole.
The coverage guarantee (the hard part). Trimming PR scope is only safe if the full suite gates somewhere before release. Current state:
- push:main
build-quality.ymlruns no tests — it only gates determinism + cache hit-rate. Gap: nothing runs the full suite on merge to main today except the PR that merged it. - nightly
selfhost.ymlruns the fullmake check(triple-pass) on Linux + macos-26.
So Lever 3 requires closing the merge-gate gap: either (i) add a
full-suite job to build-quality.yml (push:main) — preferred, it’s the
merge gate — or (ii) accept nightly as the full-suite backstop and
document that a smoke-only PR can land a macOS-specific regression that
surfaces ≤24h later in nightly. Recommendation: add a full-suite
sharded run to push:main so the full suite gates on every merge, and PRs
run smoke-on-macOS + full-on-Linux. That keeps total signal: every line
of test still runs on every merge, just not on every PR macOS leg.
Risk: medium-high — this is the lever that actually removes coverage from the PR gate. The mitigations (full suite on push:main, Linux stays full on PR) keep the net signal, but a macOS-only regression gets a slightly longer feedback loop (merge instead of PR). Only pursue after Levers 1+2 land, and only if their wall-time wins prove insufficient.
Expected wall delta: macOS PR leg → smoke subset (~2-4 min of tests)
- build. But the win overlaps heavily with Lever 1 — if sharding already gets macOS to ~12 min, Lever 3’s marginal value is smaller and its coverage cost is real. Treat as the last lever, not the first.
Lever 4 — Within-shard --jobs parallelism (Phase 1 follow-on, shipped 2026-07-01)
Idea. Levers 1–3 parallelize across CI legs but each leg still runs
its shard serially internally (sailfin test --jobs 1). The multi-file
runner’s bounded worker pool (--jobs N, #1236) is already shipped and
proven equivalence-safe (runner_jobs_parallel_test.sfn), but CI never
opted in — the serial-internal default was calibrated on the 3.23 GiB heavy
compiler-module emit (SFEP-0022 §2.4), which CI shards never build.
Benchmark (2026-07-01, self-hosted 0.7.0-alpha.50, on a 4-vCPU/16 GiB
box == the ubuntu runner). Wall time + tree-wide peak RSS (summed across
all sailfin/clang/ld processes) per shard, sweeping --jobs:
| shard | jobs=1 | jobs=2 | jobs=3 | jobs=4 | peak RSS |
|---|---|---|---|---|---|
e2e-c (build-spawners, heaviest type) |
389.7s | 218.9s (1.78×) | 189.4s (2.06×) | 173.7s (2.24×) | 1.6 → 2.1 GiB |
unit-a (frontend compiles) |
596.4s | — | — | 88.4s (6.75×) | 3.6 → 3.2 GiB |
Peak RSS stays flat or drops under parallelism, topping out at 3.6 GiB —
22% of the 16 GiB runner — because test-fixture emits are ~30× lighter than
a heavy compiler module. Memory was never the binding constraint for the
test surface. e2e shards gain ~2× (each test already spawns a nested
multi-core sfn build); frontend/unit shards gain ~6× (single compiles that
leave cores idle on link/spawn stalls when serial).
Measurement method / accuracy. The
peak RSScolumn is the tree-wide anonymous working set from a 0.2 spssampler (matchedsailfin/clang/ld…process names; page cache excluded). This box has no GNU/usr/bin/time, so per-modulemake benchmemory reads 0 — a separate limitation that does not affect these figures. Cross-checked against the kernel’s own cgroup accounting (memory.max_usage_in_bytes, reset then read after one run): e2e-c @ jobs=3 charged 3.18 GiB total vs the sampler’s 2.1 GiB — the ~1 GiB delta is reclaimable page cache from the IR/object/binary writes (evicted under pressure before any OOM), plus a little RSS the name filter misses. So the honest ceiling is ~3.2 GiB cache-inclusive / ~2.1 GiB hard working set; both sit far under the 16 GiB (Linux) / 7 GiB (macOS) budgets, and the jobs=3/2 decision holds under the higher, kernel-measured number. macOS remains the extrapolated case (noRLIMIT_ASbackstop) pending one CI confirmation run.
Shipped. scripts/test_shards.sh run reads SAILFIN_TEST_JOBS (default
1 = byte-identical serial; non-numeric → serial, which also blocks
word-injection into the unquoted expansion); the sailfin-build action
forwards it via a shard_test_jobs input; ci.yml sets 3 on Linux legs
and a conservative 2 on macOS legs. macOS is conservative because Darwin
has no RLIMIT_AS backstop (SFEP-0022) and the runner is 7 GiB/3-vCPU;
its value should be confirmed against one real CI run before raising, per
SFEP-0022 §7. The compiled-in runner default stays 1 for local determinism
and arbitrary callers. Emit fan-out (_cr_resolve_jobs, clamp [1, 8]) is
unchanged — it already resolves to 3 (Linux) / 1 (macOS 7 GiB) on
GitHub runners, so raising the clamp buys nothing until runners exceed 8
cores.
Observed but out of scope:
effect_gate_build_path_entry_test.sfn(line 129) fails standalone in a fresh container (exit 134) — identical at every--jobslevel, so parallelism-independent. Tracked separately.
5. Reconciliation with existing epics
- #843 Phase 4 (post-1.0, blocked on
routine/spawn). This plan does not unblock or pull forward in-process--jobsparallelism. It achieves the same wall-time goal via OS-level CI sharding (Lever 1) and caching (Lever 2), neither of which needs the concurrency runtime. When Phase 4 lands post-1.0,sailfin test --jobs Nbecomes an additional in-leg multiplier composable with sharding. Update02-phases.mdto note that the CI-sharding path (this doc) is the pre-1.0 stand-in for 4.1’s wall-time goal; 4.1 remains the in-process story. No conflict. - #513 Makefile slim-down (sfn owns build/check/cross/stamp). Lever 1
Seam B (
sailfin test --shard I/N) and Lever 2 (cache in the runner) both move logic intosfn, aligning with #513. The Phase-1 CI-only Seam A is a temporary bash/Makefile seam; flag it for migration to Seam B so we don’t accrete shard logic inci.ymllong-term. - #940 (multi-file runner forking). Lever 2 builds directly on the
per-invocation runtime-objdir warming (
cli_commands.sfn:1121-1141) and the per-file subprocess shape. The binary cache slots into_run_test_child/_clang_link_test_cmd_with_deps— check the cache before lowering+linking, populate it after a successful link. - #1011 (macOS parallel-build contention). Lever 1 makes each shard a
separate runner (no shared scratch — strictly safer than today’s
single-runner BUILD_JOBS=2). Lever 2’s cache writes must be atomic
(rename-into-place) to be shard-safe. The
xargs -Pintra-shard variant (NOT recommended for Phase 1) would reintroduce single-runner contention and must isolateSAILFIN_TEST_SCRATCHper worker — only pursue if shard count hits diminishing returns.
6. Sequencing & ownership
| Step | Lever | Where | Owner | Risk | Wall delta (macOS) |
|---|---|---|---|---|---|
| 1 | L1 Seam A: shard ci.yml matrix 2→2x4, file-list selection, per-shard make rebuild, shard-cover lint |
CI-config + thin Makefile target | CI | low | 47→~16-18 min |
| 2 | L1 1b: split build into a needs: job, share build/native + build/cache artifact across shards |
CI-config | CI | low-med | removes redundant build minutes; small wall win |
| 3 | L1 Seam B: sailfin test --shard I/N deterministic partition |
runner (cli_commands.sfn) |
compiler | low | (durability, not wall) |
| 4 | L2 2a: per-test binary cache (key + read/write in runner), atomic writes, --no-test-cache for full suite — shipped (#1230) |
runner (cli_commands.sfn + build_cache.sfn) |
compiler | med | incremental PRs 50-80% suite skip |
| 5 | L2 CI: save/restore build/cache/test-bin per shard; nightly baseline warm |
CI-config | CI | low | warms first-push hits |
| 6 | L3: smoke tag + macOS-smoke PR leg + full suite on push:main | runner tag (exists) + CI-config + content pass | CI + compiler (curation) | med-high | only if 1-2 insufficient |
Dependencies: Step 2 depends on 1. Step 3 supersedes Seam A from 1 (do after 1 ships). Step 5 depends on 4. Step 6 depends on 1 (and the push:main full-suite job) and should only proceed if 1+2 leave macOS above target.
7. Phase 1 — ship this week (safe quick win, zero compiler change)
Do exactly Lever 1 Seam A + the cover lint. This is pure CI-config plus
one thin Makefile target and touches no .sfn, so it can’t break
self-hosting, the determinism gates, or formatting:
- Add
make test-shard SHARD=<name>(or inline inci.yml) that maps a shard name to afind ... | sort | split/stridefile list and runs$(NATIVE_BIN) test <files>(and, for thee2e-shshard,make test-e2e-sh). Honorulimit -v 8388608on Linux as today. - Fan
ci.yml’s matrix toos x shard(4 shards/OS as in §4). Each leg: build (warm cache) + its shard. Keep fmt/canary/effect-gate/cross on one designated leg per OS (e.g.unit-a) so they run once, not 4x. - Add a
shard-covercheck: assertsort -uof every shard’s file list equalsfind compiler/tests -name '*_test.sfn' | sortplus the capsule glob (and the.shset for the e2e-sh shard). Fail CI if a file is uncovered or double-covered. - Keep
build-quality.yml(push:main) and nightlyselfhost.ymlexactly as-is — full coverage still gates at merge-adjacent points.
Expected result this week: macOS PR ~47 → ~16-18 min, Linux ~24 → ~10-11 min, no coverage lost, no compiler risk. Levers 2 and 3 follow as runner work and a scope decision respectively.
8. Verification
- Per-shard correctness (Phase 1): the sum of per-shard pass counts
across a full PR run equals the pre-shard
make testpass count. Concretely: run the cover lint (step 3 above) + compare aggregate═══ ...: N/M passed ═══banner totals against a baselinemake testrun on the same SHA. - No coverage drift:
shard-coverlint green on every PR. - Determinism untouched:
ci.ymlfixed-point step (ci.yml:277-330) andbuild-quality.ymlgates still pass on push:main — they don’t read the test-binary cache, and Lever 2 ships with--no-test-cacheon the full-suite/check paths. - Lever 2 cache correctness: a PR that edits one
compiler/src/*.sfndep must show cache misses for every test transitively importing it and hits for the rest (assert via acache.test_bin_hit_ratefield added to the runner’s--jsonsummary, mirroringcache.hit_rate). A no-op rerun must show ~100% test-bin hits. - Self-host + fmt (any
.sfntouched in Steps 3-6):make compile && make testgreen;sfn fmt --checkclean on touched files.
9. Future considerations (toward 1.0)
- Phase 4 composability. When
routine/spawnland,sailfin test --jobs N(4.1) multiplies within each shard; the shard map and the binary cache both survive unchanged. The--shard I/Npartition (Seam B) and--jobs Nare orthogonal axes. - #513 end-state. Seam B + the test-binary cache push more build/test
ownership into
sfn, shrinking the Makefile/CI bash surface — exactly the #513 direction. The Phase-1 bash seam is explicitly temporary. - Optional stacked follow-up (NOT prioritized here): lld/mold +
raising the macOS
BUILD_JOBScap (#343). The per-test clang link is the dominant per-test cost (§Lever 2); a faster linker compounds with both sharding and the binary cache. Mention only — out of scope for this plan.