Repository navigation
policy(rust): enforce cached cross-platform Dylint and make it a setup-soldr default for every Rust app #9
Description
Context
Under #5 (RUST-008), cross-target Dylint is the cheap way for a Rust app to catch platform bugs on every PR: check-only passes on Linux for each OS family, with no native runners. It is only affordable if it is cached. Dylint lints workspace crates only, but every pass must first type-check the entire dependency graph with Dylint's pinned nightly, and the stable build caches cannot stand in for that. Uncached, dependency checking is most of the job, and the cost multiplies with every target.
This issue asks to enforce cached cross-platform Dylint as fleet policy, and to make it automatic in setup-soldr. A Rust app should get warm host + cross-target Dylint by setting dylint: true and declaring its targets, with no per-repository cache plumbing. Every Rust app in the fleet has this problem.
It builds on #1 (Dylint cache identity mechanics), #5 (cross-target checks, cache budget, toolchain sourcing order) and #7 (kill-switched layers, green runs that save nothing). It does not duplicate them.
Evidence (zackees/clud, measured 2026-09-28)
All numbers come from job logs via gh api .../jobs/<id>/logs.
Uncached baseline
clud#1541 adds Windows MSVC and macOS arm64 cross passes to clud's single Linux Dylint job. PR run 36467947289:
| Pass | Dependency units | Workspace crates start at | Pass ends |
|---|---|---|---|
Host x86_64-unknown-linux-gnu |
405 | 113 s | 144 s |
x86_64-pc-windows-msvc |
336 | 113 s | 147 s |
aarch64-apple-darwin |
278 | 31 s | 60 s |
About 75–80% of every pass is dependency type-checking. The Dylint job took 7m45s.
Why nothing was cached
There are three independent causes, and any one of them is enough to defeat caching:
- A kill switch in the consumer. clud's
_dylint.ymlcarriedZCCACHE_DISABLE: "1"from its first version (clud #487, 2026-07-06). setup-soldr still reportedlayers=...zccache-unit..., restored a 22-byte archive (produced an unusable payload), and saved a 253-byte one. This is theCACHE-010class in policy(cache): detect kill-switched layers and green default-branch runs that save nothing (fleet-wide) #7. - soldr never writes the Dylint success marker on the
soldr dylintpath. After a greensoldr dylint --allrun, the file named bySOLDR_DYLINT_SUCCESS_MARKERdoes not exist. A diagnostic step in dispatch run36471128435printed(marker missing).- The marker is written only by the cargo front door (
cargo_front_door/run.rsaround L888, whendylint_entrypoint). - Because the marker is missing, setup-soldr's
post.ts(around L2330) skips both Dylint layers on every run, including greenmainruns:dylint-cache: no matching successful Dylint marker - skipping saveanddylint-output-cache: Dylint did not complete successfully - skipping save. Seemainrun36463271709. - Even if the marker were written, the identity strings disagree, as fix: detect Dylint recompilation and cache identity drift in fast CI #1 noted:
- soldr writes
channel|release|commitwith a host-qualified channel (nightly-2026-05-28-x86_64-unknown-linux-gnu). - setup-soldr compares against the short
nightly-2026-05-28.
- soldr writes
- Both are present in soldr 0.9.25 and setup-soldr
67ed401.
- The marker is written only by the cargo front door (
- The output-cache key includes the commit.
resolve-setup.tshashessource_revision: githubShaintosetup-soldr-dylint-output-v1-*, and the key is restored exact-only. Every new commit misses by design, so even a working save would never help a PR.
Measured fix
The experiment re-enabled zccache and used a fresh suffix, dylint-zccache, because the empty entry on main is immutable under its key. It ran as two workflow_dispatch runs of the same workflow on a branch; save-cache: auto saves on dispatch:
| Pass | Before (uncached PR run) | Cold seed 36471128435 |
Warm 36472266045 |
Warm zccache hits |
|---|---|---|---|---|
| Host | 2m45s | 3m06s | 28s | 551 HIT / 0 MISS |
| Windows MSVC | 2m41s | 3m22s | 49s | 417 HIT / 0 MISS |
| macOS arm64 | 1m21s | 1m38s | 69s | 322 HIT / 0 MISS |
| Whole Dylint job | 7m45s | 9m13s | 3m31s | — |
- Size: the zccache entry for all three targets is 589 MB. That is not small (about 1,290 check units, including rmeta), but it is under half the size of one of clud's per-triple build caches (1.0–1.5 GB each).
- Budget: clud is already over quota at 10.49 GB, so this layer must be budgeted under policy(rust): make coverage-per-compile-minute the objective; require cheap cross-target checks, a cache budget, and a toolchain sourcing order #5
GEN-009, not added on top.
What a warm cross pass still pays: toolchain downloads every run
| Pass | Downloaded each run | Time |
|---|---|---|
| Windows | target rust-std, LLVM 21.1.5, xwin-cache MSVC sysroot, 4 system libraries |
~16 s |
| macOS | target rust-std, llvm-tools 20.1.7 (~25 s alone), Apple SDK 14.5, 4 system libraries |
~40 s |
That is sourcing tier 2 (downloaded) in #5's order, when it should be tier 1 (vendored or cached in setup-soldr). With it cached, the warm macOS pass would drop from 69s to about 30s.
Proposal
Policy rules
| Candidate ID | Mechanical signal |
|---|---|
RUST-013 |
A Rust app's quick-gate Dylint does not run with a dependency layer that is keyed on the nightly compiler identity + Cargo.lock + target set, restored across commits (not keyed on the source SHA), and saved from the default branch. Evidence required: a green default-branch run saves a non-trivial layer, and the next run on a new commit shows at least 90% dependency-unit hits for every declared Dylint target. A kill switch (ZCCACHE_DISABLE, soldr --no-cache cargo dylint) or a SHA-only key is a violation. |
CACHE-013 |
A green Dylint run leaves setup-soldr's success marker absent, or with an identity that differs from setup-soldr's, so the foundation/output layers skip saving. Detect no matching successful Dylint marker / did not complete successfully - skipping save on a green default-branch run. |
setup-soldr: make it the default, not per-repo plumbing
- Declared cross targets. In Dylint mode, accept the targets (or derive them from soldr's target declarations) and run the host plus per-target check-only passes.
soldr lint rust --cross-dylint --target ...(soldr#3428) already exists. - Dependency layer on by default. In Dylint mode, zccache-unit (or
soldr dylint cook, Linux-only, default-branch save per policy(rust): make coverage-per-compile-minute the objective; require cheap cross-target checks, a cache budget, and a toolchain sourcing order #5RUST-010) holds the nightly check units:- Key: nightly identity + lockfile + target set.
- One generation per key.
- A warning when a kill switch contradicts the layer (policy(cache): detect kill-switched layers and green default-branch runs that save nothing (fleet-wide) #7).
- Fix the success marker. The
soldr dylintpath writesSOLDR_DYLINT_SUCCESS_MARKER, and the identity is normalized between the short and host-qualified channel in one owner: soldr, setup-soldr, or a shared contract. - Output cache. Remove
source_revisionfrom the key or add a lockfile-scoped restore prefix. Alternatively, retire this layer in favor of the dependency layer, if the workspace-crate part is cheap. It is (about 25–30s per pass here). - Cross sysroots and
rust-std. Cache each declared target's nightlyrust-stdwith the Dylint foundation. Reuse or vendor the prepare cache family for LLVM,llvm-tools, the MSVCxwinsysroot and the Apple SDK, so a warm pass downloads nothing. - Report layer size and hit rate per target in the final cache summary, so
RUST-013can be checked from logs.
Acceptance criteria
RUST-013andCACHE-013are added topolicy-rust.md/policy-general.md, each with its signal and fixtures.- RED fixtures:
- clud PR run
36467947289(cold, kill switch) - clud
mainrun36463271709(green, nothing saved) - dispatch run
36471128435(marker missing)
- clud PR run
- GREEN fixture: dispatch run
36472266045(100% hits, 28s/49s/69s passes). - setup-soldr acceptance:
- A consumer that sets only
dylint: trueplus its Dylint targets gets, on a new commit after one default-branch seed:- at least 90% dependency hits per target
- no marker skip
- no toolchain or sysroot downloads on the warm run
- The Dylint layers stay within a declared budget. The 589 MB for three targets is the reference point.
- A consumer that sets only
- A fleet sweep lists every Rust app with Dylint and reports per target: cached or cold, hit rate, and layer size.
Decisions
- Priority P1. Cross-platform Dylint on every PR is the main cheap platform-coverage lever (policy(rust): make coverage-per-compile-minute the objective; require cheap cross-target checks, a cache budget, and a toolchain sourcing order #5). Uncached, it costs about 2.5 minutes per target, so repositories drop targets instead.
- The cache belongs in setup-soldr. clud's fix (clud#1541: remove the kill switch, fresh suffix, regression test) proves the numbers, but copying it into every repository is exactly the drift policy(cache): detect kill-switched layers and green default-branch runs that save nothing (fleet-wide) #7 describes.
- The owner's assumption was that the cache is small because it holds no compiled artifacts. The measured size is 589 MB for three targets: check units, not linked products, so it is allowed under policy(rust): make coverage-per-compile-minute the objective; require cheap cross-target checks, a cache budget, and a toolchain sourcing order #5, but it is not free. It must be budgeted.
Open questions
- One combined key for all Dylint targets, or one per target, so that adding a target doesn't invalidate the others?
- Should workspace units be excluded from the saved dependency layer? They change on every commit and are cheap to recheck.
- Should zccache-unit or
soldr dylint cookbe the canonical Dylint dependency layer? policy(rust): make coverage-per-compile-minute the objective; require cheap cross-target checks, a cache budget, and a toolchain sourcing order #5 says cook is Linux-only and saves only on the default branch; both fit that.
Activity