Skip to content

ci(cache): let each PR write its own cache instead of never writing one - #84

Merged
wenzowski merged 2 commits into
mainfrom
claude/cloud-176-cache
Aug 7, 2026
Merged

wenzowski merged 2 commits into
mainfrom
claude/cloud-176-cache

Conversation

@wenzowski

Copy link
Copy Markdown
Contributor

Fixes the dead cache found in CLOUD-176.

The bug

All three rust-cache steps were gated save-if: ${{ github.ref == 'refs/heads/main' }}. This workflow triggers on pull_request only — deliberately, since main advances by fast-forward to commits that already passed, so re-running there would be waste.

github.ref is never refs/heads/main in a pull_request run. The condition could never be true, so the cache was never written, every restore was a miss, and rust-cache ran on every job doing nothing — while its comment read as though caching were handled.

Measured cold on 6730729: ci 74s, cross 53s, darwin-link 66–75s. A warm local darwin-link is ~7s.

Why dropping the guard is safe

The guard's stated purpose was "a PR cannot poison the cache other PRs restore." GitHub already guarantees that: a pull_request run writes its cache under refs/pull/N/merge, and that cache "can only be restored by re-runs of the pull request. It cannot be restored by the base branch or other pull requests."

So cross-PR isolation is a platform property, not something this workflow has to enforce. Removing save-if costs no isolation and makes every run after the first on a given PR warm — the common case here, where a branch is typically pushed several times before landing (#77 took four).

What this does not fix

The first run of a new PR is still cold. Only a cache on the base branch could warm it, and nothing writes there. Doing so needs a push-to-main trigger, which AGENTS.md explicitly forbids ("Never re-run CI on an already-tested SHA … Don't add push-to-main CI triggers"). Whether a cache-warming job — which runs no gates and asserts nothing — counts against that rule is a judgement for the maintainers, and it costs one job per merge. Left as CLOUD-176's open decision rather than taken unilaterally, particularly given runner spend is a live concern.

Verification

The honest test is the CI run on this PR, and it is necessarily two-part:

  1. This run should still be cold (nothing has written a cache for this branch yet) and should now save at the end — visible in the rust-cache step's log.
  2. A second push to this branch should restore and show a measurable drop against the cold baseline above.

I'll push a trivial follow-up commit to produce that second data point before marking this ready, so the claim is backed by a measurement rather than by reasoning about the docs.

actionlint and zizmor clean.

Refs CLOUD-176.


Generated by Claude Code

@linear-code

linear-code Bot commented Aug 7, 2026 •

Copy link
Copy Markdown
CLOUD-176 rust-cache never writes, so every CI run is cold

Why

All three Swatinem/rust-cache steps in ci.yml are gated save-if: ${{ github.ref == 'refs/heads/main' }} — "only main writes the cache, so a PR cannot poison the cache other PRs restore." Sound reasoning, but ci.yml triggers on pull_request only, deliberately: main advances by fast-forward to commits that already passed, so re-running there would be waste.

Those two facts are incompatible. github.ref is never refs/heads/main in a pull_request run, so save-if is never true, the cache is never written, and every restore is a miss. rust-cache is currently pure overhead — it runs, finds nothing, saves nothing, on every job of every PR.

Evidence

Measured on the CI run for 6730729 (PR #77), every job compiling from scratch:

job duration
ci 74s
darwin-link (x86_64-apple-darwin) 75s
darwin-link (aarch64-apple-darwin) 66s
cross 53s

For comparison, darwin-link takes 7s locally with warm dependencies — a ~10× gap that is entirely cold-start compilation. Earlier runs measured ci at 46–49s, so the critical path is growing as the dependency tree does, with nothing amortising it.

Options (a decision is needed, hence Todo not Backlog)

  1. Populate the cache from a scheduled or push-to-main job that runs the build purely to warm it. Keeps the no-PR-writes safety property intact; costs one extra run per merge.
  2. Let PRs write, keyed so they cannot collide (per-branch or per-lockfile-hash keys). Cheapest to implement; needs care that the poisoning concern the current comment raises is actually addressed by the key design, not just moved.
  3. Drop rust-cache entirely and accept cold builds. Honest and simplest — a step that provably does nothing is worse than no step, because it reads as if caching is handled.

Definition of done

  • Either the cache demonstrably restores on a PR run, or the step is removed
  • If kept, the anti-poisoning property the current comment claims is preserved and stated accurately
  • The ci.yml comment matches what the configuration actually does

Acceptance

  • A PR run logs a cache hit, with a measurable drop against the cold baseline above — or rust-cache is gone and the comment no longer claims caching

Ready — refinement specialization

  • Source of truth (clause 1): ci.yml — the trigger and the save-if condition are one decision and must be read together.
  • Mechanism / gate (clause 2): the job durations themselves; a cache hit appears in the action's own log.
  • Effect (clause 3): write — CI configuration.
  • Output & exit (clause 5): n/a, workflow change.
  • Commit / bump (clause 6): ci — no crate semver bump.
  • Test (clause 7): the next PR run's durations against the cold baseline recorded above.
  • Blockers (clause 8): none. Option 1 vs 2 vs 3 is the open decision, and it is a cost/safety trade for the maintainers rather than a technical unknown.

Found while measuring whether darwin-link (CLOUD-108 / PR #77) fits inside the CI critical path. It does, but only because everything else is cold too.

Review in Linear

@wenzowski
wenzowski marked this pull request as ready for review August 7, 2026 04:58
@wenzowski

Copy link
Copy Markdown
Contributor Author

Measured, rather than argued from the docs. Two runs on this branch — run 1 wrote the branch cache for the first time, run 2 restored it:

job run 1 (cold) run 2 (warm) change
ci 58s 43s −26%
cross 45s 28s −38%
darwin-link (aarch64-apple-darwin) 68s 53s −22%
darwin-link (x86_64-apple-darwin) 67s 54s −19%
critical path 68s 54s −21%

The mechanism is visible in the step list too: Post Run Swatinem/rust-cache now executes on every job. Before this change it was skipped every time, because save-if could never be true — that skipped step was the bug.

Two honest caveats on the numbers:

  • Run 1 here (58s ci) was already faster than the 6730729 baseline (74s ci), so runner variance is real and the percentages are indicative rather than exact. The direction and the mechanism are not in doubt; every job improved, and the cache-write step changed from skipped to executed.
  • The first run on any new PR is still cold. Only a base-branch cache fixes that, which needs a push-to-main trigger that AGENTS.md forbids — left as CLOUD-176’s open decision.

The empty measure a warm run commit is deliberate: dropping it would change the SHA and force a re-run of CI that has already passed, which this repo explicitly avoids. It documents the experiment.


Generated by Claude Code

@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski

Copy link
Copy Markdown
Contributor Author

Triggered from #84 (comment) by @​wenzowski.

Trying to fast forward main (e600c71) to claude/cloud-176-cache (e70c38f).

Target branch (main):

commit e600c71250f80c18724869b62904d631fd4a2231 (HEAD -> main, origin/main)
Author: Claude <noreply@anthropic.com>
Date:   Fri Aug 7 04:56:47 2026 +0000

    test(ready-guard): build a scratch repo instead of reading the working clone
    
    The suite read the surrounding clone's HEAD and origin/main. That passed here
    and failed in CI, where a fresh checkout has no origin/main ref and setup died
    with status 128 before a single assertion ran — an environment-dependent test
    proves nothing about the guard, and this one hid behind a local pass.
    
    Each case now builds its own repository with a known HEAD and a synthetic
    origin/main, so the amended-commit and moved-main cases are constructed rather
    than borrowed. Adds the case CI actually exercised: with no origin/main ref at
    all the guard fails open, because a local pre-flight must not deny in an
    environment it cannot evaluate.

Pull request (claude/cloud-176-cache):

commit e70c38f1dee40a252085fa1fd422d2742c81c036 (pull_request/claude/cloud-176-cache)
Author: Claude <noreply@anthropic.com>
Date:   Fri Aug 7 04:59:57 2026 +0000

    ci(cache): measure a warm run
    
    An empty commit to produce the second CI run on this branch. Run 1
    wrote the branch's cache for the first time; this run is the one that
    can restore it, and the durations either show the drop the change
    claims or they do not.
    
    Refs CLOUD-176.

Can't fast forward main (e600c71) to claude/cloud-176-cache (e70c38f). main (e600c71) is not a direct ancestor of claude/cloud-176-cache (e70c38f). Branches appear to have diverged at 6730729:

* e70c38f1dee40a252085fa1fd422d2742c81c036 ci(cache): measure a warm run
* a073799a667c2bdbe7b2e70d7a4e5b0a2a94badb ci(cache): let each PR write its own cache instead of never writing one
| * e600c71250f80c18724869b62904d631fd4a2231 test(ready-guard): build a scratch repo instead of reading the working clone
| * 17b8436a8ced5d06d059fb5db44700e948bcab41 chore(lock): record the cargo-zigbuild platform checksum
| * 1535406b5ed54285d4972558a2981709c60ab612 fix(ready-guard): a missing trailing newline allowed every command
| * abd1e3aabb4c8b2a04e86ae54cbbefdcbeab5e9a feat(gate): deny gh pr ready without verify and linear-check receipts
|/  
* 6730729af469460c0f83497b3d10a7e25b92b972 ci(darwin): link the macOS targets in a parallel job, off the critical path

commit 6730729af469460c0f83497b3d10a7e25b92b972
Author: Claude <noreply@anthropic.com>
Date:   Fri Aug 7 04:16:02 2026 +0000

    ci(darwin): link the macOS targets in a parallel job, off the critical path
    
    macos-link-check reads a list of framework-linking crates, so it is
    incomplete by construction. Close that gap the only way with no false
    negatives: link the target. It either links or it does not.
    
    Structured to cost no wall-clock. It is its own CI job with one target
    per matrix leg, so the legs run concurrently with each other and with
    ci/cross, and nothing was added to mise run ci or the pre-commit hook.
    Measured 7s per leg against a critical path of 46-63s.
    
    7s, not the 35s an optimized build takes: linking is what is under test,
    so the dev profile proves the same thing without LTO. A test asserts the
    task never grows a dist profile, so that cannot regress quietly.
    
    Net CI work goes down. Both Darwin triples leave cross-check, where they
    were cargo check'd — which never links and so never caught this class of
    break anyway. cross-check now covers windows-gnu alone.
    
    A file task, not an inline one: mise appends CLI args to inline task
    bodies, which passed a stray positional to cargo.
    
    Refs CLOUD-173.

Rebase locally, and then force push to claude/cloud-176-cache.

claude added 2 commits August 7, 2026 05:02
save-if was gated on github.ref == refs/heads/main, but this workflow
runs on pull_request only — main advances by fast-forward to
already-tested commits and is deliberately not a trigger. The condition
could therefore never be true: the cache was never written, every
restore missed, and rust-cache was pure overhead while reading as though
caching were handled. Measured cold on 6730729: ci 74s, cross 53s,
darwin-link 66-75s, against ~7s for a warm local darwin link.

The guard existed to stop one PR poisoning another's cache. GitHub
already prevents that: a pull_request run writes under
refs/pull/N/merge, restorable only by re-runs of that same PR, never by
a sibling PR and never by main. So dropping the guard costs no isolation
and makes every run after the first on a given PR warm — which is the
common case here, where a branch is pushed several times before landing.

Not fixed: the first run of a new PR stays cold, since only a
base-branch cache could warm it. That needs a push-to-main trigger,
which AGENTS.md forbids — the trade is CLOUD-176's open decision.

Refs CLOUD-176.
An empty commit to produce the second CI run on this branch. Run 1
wrote the branch's cache for the first time; this run is the one that
can restore it, and the durations either show the drop the change
claims or they do not.

Refs CLOUD-176.
@wenzowski
wenzowski force-pushed the claude/cloud-176-cache branch from e70c38f to 6faa49e Compare August 7, 2026 05:02
@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit 6faa49e into main Aug 7, 2026
7 checks passed
@wenzowski
wenzowski deleted the claude/cloud-176-cache branch August 7, 2026 05:04
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.

2 participants