I reviewed a PR last week that switched a Bazel CI workflow from actions/cache@v4 to the split actions/cache/restore + actions/cache/save pattern. The PR had a subtle bug — asymmetric path between save and restore — and a less-subtle design issue: uv.lock in the cache key for a tool that doesn’t read uv.lock.
It turned into one of those reviews where you end up explaining the entire caching model from scratch. So here’s that explanation, written down, so I never have to type it in a PR comment again.
Three actions, not one
GitHub gives you three cache actions. Most people only know the first one.
| Action | What it does |
|---|---|
actions/cache@v4 |
Restore at the start, save at the end (combined) |
actions/cache/restore@v4 |
Restore only |
actions/cache/save@v4 |
Save only |
The combined action is convenient for simple cases. It registers a post-job hook that automatically saves the cache after your job finishes, regardless of which step you’re on.
The split actions exist because the combined one makes an assumption: every run should both read and write the cache. That’s often wrong.
The split pattern: save only on main
Here’s the most common reason to split: you want PR branches to read the cache but not write to it.
- uses: actions/cache/restore@v4
with:
path: ~/.cache/bazel
key: bazel-${{ runner.os }}-${{ hashFiles('.bazelversion') }}
restore-keys: |
bazel-${{ runner.os }}-
# ... your build steps ...
- uses: actions/cache/save@v4
if: github.ref == 'refs/heads/main'
with:
path: ~/.cache/bazel
key: bazel-${{ runner.os }}-${{ hashFiles('.bazelversion') }}
Why? Every cache entry GitHub stores counts against a 10GB per-repo limit. Old entries get evicted LRU. If every PR branch writes its own cache entry, you get dozens of entries that are read exactly once (by the subsequent runs of that PR) and then never again. They push out the main branch’s cache — the one that every run actually needs.
Save on main, restore everywhere. PRs freeload off main’s cache via restore-keys prefix matching. The cache stays small, the hit rate stays high.
Pair this with a nightly or scheduled run on main to keep the cache fresh even during quiet periods.
Cache key design — the core of it
This is where most people get it wrong. Not because it’s complicated, but because the mental model isn’t obvious.
A cache step has two fields that matter:
key— the exact string used for both lookup and storagerestore-keys— a fallback list of prefixes, tried in order ifkeydoesn’t match exactly
When your step runs:
- Try an exact match on
key - If miss: try each
restore-keysentry as a prefix, in order - First prefix match wins — that cache gets restored
- If nothing matches: cold miss, no cache
After the job (or when save runs): the cache is stored under the exact key string. This only happens if key wasn’t an exact hit during restore — GitHub won’t overwrite an existing entry.
How restore-keys fallback actually works
This is the part that trips people up. restore-keys aren’t exact matches — they’re prefixes.
key: bazel-Linux-7.5.0-abc123
restore-keys: |
bazel-Linux-7.5.0-
bazel-Linux-
If the exact key bazel-Linux-7.5.0-abc123 isn’t in the cache, GitHub looks for any entry whose key starts with bazel-Linux-7.5.0-. If there’s one from yesterday’s run — bazel-Linux-7.5.0-def456 — it’ll restore that.
The restored cache might be stale. It might have extra stuff in it. That’s fine. For most tools, a partial hit is dramatically better than a cold miss. Your build tool will just rebuild the delta.
The progressively shorter prefixes form a fallback chain: try for a very close match first, then settle for an increasingly approximate one. The design is: how far back are you willing to reach for some cache rather than none?
A real example (and what was wrong with it)
Here’s the cache key from the PR I reviewed:
key: bazel-${{ runner.os }}-${{ hashFiles('.bazelversion') }}-${{ hashFiles('MODULE.bazel') }}-${{ hashFiles('uv.lock') }}
restore-keys: |
bazel-${{ runner.os }}-${{ hashFiles('.bazelversion') }}-${{ hashFiles('MODULE.bazel') }}-
bazel-${{ runner.os }}-${{ hashFiles('.bazelversion') }}-
bazel-${{ runner.os }}-
Looks reasonable. Four segments: OS, Bazel version, module deps, Python deps. Progressively shorter restore-keys. The structure is textbook.
But uv.lock in the key is wrong.
Bazel doesn’t read uv.lock. It has its own dependency resolution through MODULE.bazel and its own content-addressed invalidation — if an input changes, Bazel recomputes the action hash and rebuilds that target. It doesn’t care what uv.lock says.
So when someone runs uv lock and the lockfile changes — which happens regularly — the cache key changes. Full cache miss. Tens of minutes of cold rebuild. For a file that Bazel never looks at.
The fix: drop uv.lock from the key. Keep MODULE.bazel because Bazel does read that for external dependency resolution. The result:
key: bazel-${{ runner.os }}-${{ hashFiles('.bazelversion') }}-${{ hashFiles('MODULE.bazel') }}
restore-keys: |
bazel-${{ runner.os }}-${{ hashFiles('.bazelversion') }}-
bazel-${{ runner.os }}-
Fewer segments, fewer misses, same correctness.
The principle: match the key to the tool
This is the general rule: only include files in the cache key that the tool actually reads to decide what to rebuild.
Different tools need different key strategies because they have different invalidation models.
Tools with built-in invalidation
Bazel, Gradle, Turborepo — these tools hash their inputs internally. They know exactly what changed and rebuild only that. The cache you’re persisting between runs is just a warm starting point; the tool handles the rest.
For these, you want simple keys. A version identifier plus maybe a coarse dependency hash. The tool’s own invalidation handles the fine-grained stuff. Over-keying just causes unnecessary cold misses.
# Bazel: version + module deps is enough
key: bazel-${{ runner.os }}-${{ hashFiles('.bazelversion') }}-${{ hashFiles('MODULE.bazel') }}
# Gradle: wrapper version + dependency config
key: gradle-${{ runner.os }}-${{ hashFiles('**/gradle-wrapper.properties') }}-${{ hashFiles('**/*.gradle*') }}
Tools without invalidation
node_modules, pip’s cache, Go’s module cache — these are just directories of downloaded stuff. The tool doesn’t know whether the cache is stale. If the lockfile changed and you restored a stale cache, you might get the wrong versions.
For these, you want the lockfile hash in the key. When dependencies change, the key changes, you get a miss, and you download fresh. The restore-keys fallback gives you a partial hit (most packages haven’t changed), which is still faster than cold.
# Node: lockfile hash is essential
key: node-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
node-${{ runner.os }}-
# pip: requirements hash
key: pip-${{ runner.os }}-${{ hashFiles('**/requirements*.txt') }}
restore-keys: |
pip-${{ runner.os }}-
The spectrum:
| Tool | Invalidation model | Key strategy |
|---|---|---|
| Bazel | Content-addressed action hashing | Simple — version + coarse deps |
| Gradle | Incremental compilation, input tracking | Moderate — wrapper + config hashes |
| node_modules | None — it’s just a folder | Lockfile hash required |
| pip | None | Requirements hash required |
| Go modules | Checksum DB, but no local invalidation | go.sum hash required |
Cache scope and branch fallback
GitHub scopes cache entries to branches, and the scoping rules matter:
- A workflow run can restore caches from the same branch or the default branch (usually
main) - A workflow run can only save to its own branch scope
- Caches from other feature branches are invisible
This is why the save-on-main pattern works. When a PR branch runs, it can’t see caches from other PR branches — but it can see main’s cache. Main’s cache becomes the shared warm starting point for every PR.
When one repo has six caches
Everything above assumes you’re caching one thing. In practice, a real CI workflow caches several. A Bazel monorepo is the extreme case — there are at least five or six distinct caches, each with different invalidation properties, and each needing a different key strategy.
Here’s what that landscape actually looks like.
Bazel build cache
Path: ~/.cache/bazel (the --disk_cache directory)
This is the big one. Bazel’s disk cache stores action results in a content-addressable format: action cache (AC) maps action hashes to output metadata, and content-addressable storage (CAS) stores the actual output files by their content hash. Same inputs + same command = same hash = cache hit. Different inputs = different hash = rebuild that target, leave everything else alone.
The key insight: stale entries in a Bazel cache are harmless. They’re just extra entries that will never match. Bazel doesn’t look at what’s in the cache and go “oh, this must be current” — it computes what the hash should be and either finds it or doesn’t. So restoring yesterday’s cache, or last week’s cache, is fine. You’ll have some dead weight, but zero correctness risk.
This means the GHA key can be very coarse:
key: bazel-${{ runner.os }}-${{ hashFiles('.bazelversion') }}
restore-keys: |
bazel-${{ runner.os }}-
That’s it. .bazelversion because a Bazel upgrade changes the cache format. OS because cross-platform cache sharing doesn’t work. You could add MODULE.bazel if you want a fresh cache when external deps change significantly — reasonable, but optional. Bazel will figure out the delta either way.
What you should not add: uv.lock, package-lock.json, requirements.txt, or any lockfile for a tool Bazel doesn’t invoke. Bazel has its own dependency universe. Keying on files outside that universe just burns your cache hit rate for no benefit.
The tradeoff to watch is cache size. Bazel’s disk cache grows unboundedly — every new action result gets added, nothing gets evicted unless you do it yourself. In a big monorepo, this can hit 50-200GB. GitHub gives you 10GB. If your Bazel cache is regularly exceeding the limit, actions/cache might not be the right tool at all — look at persistent disks on self-hosted runners or remote cache instead.
Remote cache
Config: --remote_cache=grpcs://your-cache-server.example.com
BuildBuddy, Google RBE, Bazel Remote — these are shared cache servers that multiple CI jobs (and developers) can read from and write to. The protocol is the same AC+CAS content-addressed model, just over the network.
If you have remote cache, the calculus changes completely. Your GHA cache is no longer about persisting build artifacts — the remote cache handles that. The GHA cache is now about repo setup time: things like the Bazel install, external repository fetches, and the analysis cache. These are smaller, cheaper, and less important to get right.
In fact, with a warm remote cache, many teams skip GHA caching for Bazel entirely. The build starts cold, fetches its remote repos (maybe 30 seconds), then every action hits the remote cache. The total overhead is small enough that the complexity of managing GHA cache entries isn’t worth it.
The one exception: if your remote cache is on the other side of the planet (or your builds are latency-sensitive), a local disk cache in front of the remote cache can help. Bazel supports this — --disk_cache acts as an L1, --remote_cache as L2. In that setup, the GHA cache is persisting the L1 disk cache, and the key strategy should be the same simple one described above.
Bazelisk
Path: ~/.cache/bazelisk
Bazelisk is the version manager — it reads .bazelversion, downloads the right Bazel binary, and hands off. The cached artifact is a single binary, ~10MB.
Should you cache it in GHA? Probably not.
It downloads in 2-3 seconds on GitHub’s runners. Caching it means another path entry to keep in sync between save and restore (we saw how that goes wrong), another thing consuming your 10GB budget, and a savings of… 2 seconds. The ROI is negative once you account for the maintenance cost.
If Bazel itself takes 20 minutes and you’re trying to shave off 2 seconds of Bazelisk download, you’re optimizing the wrong thing.
Docker layer cache
Config: cache-from: type=gha / cache-to: type=gha,mode=max (in docker/build-push-action)
If your CI also builds Docker images — and in a monorepo, it almost certainly does — Docker has its own GHA cache integration that uses the same underlying storage.
Docker’s layer invalidation is less sophisticated than Bazel’s. A Dockerfile is a linear sequence of layers. If layer 5 changes, layers 6 through N all rebuild, even if they’re logically independent. The cache is a stack, not a DAG.
This means GHA cache keys matter more for Docker than for Bazel. Docker can’t look at a stale cache and surgically rebuild just the changed parts — it rebuilds everything from the first invalidated layer onward. So you want keys that change only when the Dockerfile or its inputs actually change:
# Docker image cache — key on Dockerfile + build context
key: docker-${{ runner.os }}-${{ hashFiles('docker/Dockerfile') }}-${{ hashFiles('uv.lock') }}
restore-keys: |
docker-${{ runner.os }}-${{ hashFiles('docker/Dockerfile') }}-
docker-${{ runner.os }}-
Notice uv.lock does belong in this key — unlike Bazel, Docker literally reads the lockfile during uv sync layers. When the lockfile changes, those layers need to rebuild. The same file that was wrong for the Bazel key is right for the Docker key. Context matters.
One gotcha: Docker’s GHA cache backend (type=gha) shares the same 10GB repo limit. A single multi-stage Docker image can easily eat 5-8GB of cache. If you’re caching both Bazel and Docker in the same repo, you’re fighting over a very small pie. Consider whether one of them would be better served by a registry-based cache (type=registry) instead.
ccache / sccache
Path: ~/.cache/ccache or ~/.cache/sccache
Bazel repos with C++ or CUDA rules often use ccache (or its Rust-flavored cousin, sccache) to cache preprocessed compilation units. This sits underneath Bazel — Bazel invokes the compiler, and ccache intercepts the call to check if it’s seen these preprocessed inputs before.
ccache has its own content-based invalidation (it hashes the preprocessed source, compiler flags, and compiler binary), so the same principle applies: simple GHA keys.
key: ccache-${{ runner.os }}-${{ env.COMPILER_VERSION }}
restore-keys: |
ccache-${{ runner.os }}-
The cache grows over time and benefits from periodic pruning. Set CCACHE_MAXSIZE to something that fits in your budget:
env:
CCACHE_MAXSIZE: 2G
CCACHE_DIR: ~/.cache/ccache
sccache is similar but supports distributed caching (S3, GCS, Azure Blob) and Rust compilation. If you’re already using cloud storage for your remote build cache, sccache can piggyback on that instead of using GHA cache at all.
pip / uv cache
Path: ~/.cache/uv or ~/.cache/pip
This one’s situational. In a pure Bazel setup where Python dependencies are managed through MODULE.bazel and pip.parse(), the pip/uv cache is irrelevant — Bazel downloads wheels itself and stores them in its own external repository cache.
In a hybrid repo — some workflows run Bazel, others run uv sync or pip install directly — you might need to cache the package manager’s download cache separately. This is a tool-without-invalidation situation, so the lockfile goes in the key:
key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
restore-keys: |
uv-${{ runner.os }}-
The mistake I see in hybrid repos: caching uv’s download cache and including uv.lock in the Bazel cache key. These are separate tools with separate caches. Bazel’s key shouldn’t know about uv, and uv’s key shouldn’t know about Bazel. Keep them independent.
The invalidation cheat sheet
Here’s the mental model in one table:
| Cache | Has own invalidation? | Stale = dangerous? | GHA key strategy |
|---|---|---|---|
| Bazel build (AC+CAS) | Yes — content hashing | No — just dead weight | Low — OS + .bazelversion |
| Remote cache | Yes — same as disk | No | Often skip GHA entirely |
| Bazelisk | N/A — single binary | No | Don’t bother caching |
| Docker layers | Partial — layer stack | Somewhat — stale layers can mask changes | Medium — Dockerfile + input hashes |
| ccache/sccache | Yes — preprocessed source hash | No — just dead weight | Low — OS + compiler version |
| pip/uv | No | Yes — wrong package versions | High — lockfile hash required |
| node_modules | No | Yes — wrong package versions | High — lockfile hash required |
The pattern: tools that hash their inputs need less help from your GHA key. Tools that are just directories of files need the lockfile in the key because they can’t tell stale from fresh.
Or put differently: content-addressable caches are self-cleaning (wrong entries are harmless). Directory-dump caches are not (wrong entries are invisible bugs). Your GHA key strategy is compensating for what the tool can’t do on its own.
Budgeting across caches
When you’re caching five things in one repo, the 10GB limit forces you to think about allocation. A rough budget for a Bazel monorepo with Docker builds:
| Cache | Typical size | Priority |
|---|---|---|
| Bazel build | 2-8GB (capped) | High — biggest build time impact |
| Docker layers | 3-8GB | High — image builds are slow |
| ccache | 1-2GB | Medium — only if C++/CUDA in CI |
| pip/uv | 200-500MB | Low — fast to rebuild |
| Bazelisk | 10MB | Skip — not worth the entry |
If Bazel + Docker alone exceed 10GB, something has to give. Common solutions:
- Move Docker cache to
type=registry(caches in your container registry, no GHA limit) - Move Bazel to self-hosted runners with persistent disks
- Move ccache to an S3/GCS bucket via sccache
- Accept that some caches will be partial — a 5GB Bazel cache is still better than cold
The general principle: use GHA cache for things that are small enough to fit and expensive enough to justify. Push the big stuff to purpose-built infrastructure.
Practical mistakes I keep seeing
Over-keying. Too many hashFiles() segments means the key changes on every other commit. You get constant cold misses and the cache is basically decorative. If your cache hit rate is below 70%, your key is probably too specific.
Under-keying. A key like bazel-Linux never changes. The cache grows without bound — new artifacts pile up on top of old ones, and nothing gets invalidated. For tools without their own invalidation, this means you might be running against stale dependencies. For tools with invalidation, it’s actually fine — the cache is just a warm start and the tool handles correctness.
Asymmetric paths. The path field must match exactly between save and restore. This was a real bug in the PR I reviewed — bazelisk was in the save path but not the restore path. The result: you upload a cache that includes bazelisk, but on restore you don’t extract it. Silent failure. No error, just a cold miss for that path every time.
Caching things that aren’t worth it. Bazelisk is a 7MB binary that downloads in 2 seconds. Caching it adds a path to manage, takes space in your 10GB budget, and saves you… 2 seconds. Not everything that can be cached should be cached.
Using combined actions/cache when you need the split. The combined action always saves. You can’t conditionally skip the save with if: because the save happens in a post-job hook, not as a step you control. If you need conditional saves — like save-only-on-main — you need the split pattern.
The 10GB budget
GitHub gives each repository 10GB of cache storage. When the limit is hit, the oldest entries (by last access time) get evicted.
This means your cache strategy has a resource management dimension. Every entry you write is taking space from every other workflow in the repo. A few things that help:
- Save on main only — dramatically reduces entry count
- Avoid fine-grained keys — one key per (OS, tool-version) is better than one key per commit
- Don’t cache small things — if it downloads in seconds, let it download
- Use
restore-keys— a prefix hit on a slightly stale cache beats a fresh cold entry
You can see your repo’s cache usage with the GitHub CLI:
gh cache list
gh cache delete --all # nuclear option
TL;DR
| Concept | Rule of thumb |
|---|---|
| Combined vs split | Split when you need conditional saves (usually: save on main only) |
key |
Only hash files the tool actually reads |
restore-keys |
Progressively shorter prefixes of key |
| Tools with invalidation | Simple keys — the tool handles correctness |
| Tools without invalidation | Lockfile hash in key — you handle correctness |
path |
Must match exactly between save and restore |
| 10GB limit | Fewer entries > more entries. Main cache > per-branch caches |
| Multi-cache repos | Budget across caches. Push big ones to external infra. |
| Content-addressable caches | Stale = harmless dead weight. Key coarsely. |
| Directory-dump caches | Stale = invisible bugs. Key on lockfile. |
The whole system is simpler than it looks. key is your cache address. restore-keys is “close enough.” The art is in choosing what goes in the key — and the answer is always: what does the tool actually need to know?
When you’ve got six caches in one repo, the art becomes: which ones does the tool handle, and which ones do you have to handle for it?