My CI was green. Every run said Cache restored from key: Linux-turbo-v1. Every run reported a cache hit. And every run got a little slower, from about 3 minutes in week one to 11 minutes by week seven.
The cache was working perfectly. That was the problem. The GitHub Actions cache never updates on an exact key hit, so I had been restoring the same 47-day-old snapshot on 312 consecutive runs, and the build was redoing everything that had changed since day one.
TL;DR
-
actions/cachedoes not save when the primary key matches exactly. The post step logs that it got a hit on the primary key and skips saving. Cache entries are immutable; you cannot overwrite a key. - A static key like
Linux-turbo-v1freezes the cache forever. It is created on the first run and restored on every run after that, with contents that drift further from your code each day. - Restoring a cache resets its eviction clock, so a frozen cache that gets hit daily never expires on its own.
- Fix for incremental caches (Turbo, ccache, mypy, Gradle build outputs): use a unique key such as
...-${{ github.sha }}plus arestore-keysprefix. Every run restores the newest match and saves a fresh entry. - Dependency caches are fine with a lockfile hash key, because their contents really are a function of the key.
What did my workflow look like?
This was the step. I suspect a lot of repos have something close to it:
- uses: actions/cache@v4 with: path: .turbo key: ${{ runner.os }}-turbo-v1 The -v1 was my idea of being responsible. If the cache ever got weird, I'd bump it to -v2. What I didn't understand is that the cache was never going to get weird in a way I'd notice. It was going to get old.
Turbo (same story with ccache, sccache, mypy's .mypy_cache, pytest's cache, Next.js .next/cache) works incrementally. It reuses outputs for inputs it has seen before and rebuilds the rest. A cache from day one covers day-one code. By day 47, most of the packages had changed at least once, so most tasks were cache misses inside a "cache hit."
Why does GitHub Actions cache never update on an exact key hit?
Because cache entries are immutable and keyed by name. When actions/cache restores a cache whose key exactly equals your key: input, it sets cache-hit to true, and the post-job step skips the save. The log line at the end of the job reads like this:
Post job cleanup. Cache hit occurred on the primary key Linux-turbo-v1, not saving cache. Even if it tried to save, it couldn't. A key that already exists cannot be written again; a second save attempt for the same key fails with a message that the cache already exists or that another job may be creating it.
The design makes sense for its main use case. If your key is npm-${{ hashFiles('package-lock.json') }}, an exact hit means the lockfile didn't change, so the dependency cache is still correct. Saving it again would be wasted upload time.
The model breaks the moment you cache something whose contents are not fully determined by the key. A build output cache is the classic case.
Why didn't the old cache just expire?
GitHub evicts caches that haven't been accessed for 7 days, and evicts older entries when a repository goes over its storage limit (10 GB by default). Both rules are based on use. My frozen cache was accessed on every single run, so its last-access time was always fresh and it was never close to the eviction line.
So the one cache that most needed to die was the one most protected from dying. Meanwhile it was small enough that the storage limit never touched it.
How do you reproduce the frozen cache in 2 minutes?
Put this in a scratch repo and run it three times with workflow_dispatch:
name: cache-freeze-demo on: workflow_dispatch jobs: demo: runs-on: ubuntu-latest steps: - uses: actions/cache@v4 with: path: stamp key: stamp-v1 - name: Show what we restored, then overwrite it run: | mkdir -p stamp echo "restored: $(cat stamp/created 2>/dev/null || echo none)" date -u +%FT%TZ > stamp/created echo "wrote: $(cat stamp/created)" Run 1 prints restored: none and saves. Run 2 and run 3 both print the run 1 timestamp as restored:, even though each run wrote a new timestamp. Open the post-job step on runs 2 and 3 and you'll see the "not saving cache" line.
Now change the key to stamp-${{ github.run_id }} and add restore-keys: stamp-. Run it twice more. Each run restores the timestamp written by the previous run. That's the behavior I thought I had all along.
How do you fix it with restore-keys?
Give every run a unique primary key, and use a prefix in restore-keys to fall back to the most recent previous entry:
- uses: actions/cache@v4 with: path: .turbo key: ${{ runner.os }}-turbo-${{ github.sha }} restore-keys: | ${{ runner.os }}-turbo- Here's what happens on each run:
- The exact key (
Linux-turbo-<this sha>) doesn't exist yet, so there's no exact hit. -
restore-keysmatches by prefix and restores the most recently created entry starting withLinux-turbo-. -
cache-hitisfalse, because the match was partial. - The post step saves the updated
.turbofolder under the new unique key.
The cache now moves forward with your code. In my repo the median CI time went back to a little over 3 minutes within a day, and stayed there.
Does a unique key per run blow up cache storage?
It creates one entry per run, so yes, you churn storage. In practice the eviction rules handle it: old entries stop being restored once newer ones exist, and the repository-level limit evicts the least recently used ones first. Two things keep it sane:
- Key on
github.sha, notgithub.run_id, if reruns of the same commit should reuse the entry. Rerunning a commit then gets an exact hit, which is correct, since the code didn't change. - Keep the cached path tight. Cache
.turboor.mypy_cache, not the whole workspace. Smaller entries mean more of them fit before eviction starts eating your dependency caches.
If you want more control, actions/cache ships separate actions/cache/restore and actions/cache/save actions. You can restore at the top of the job and only save on pushes to main, which cuts the churn from PR runs.
Which caches are safe with a static or lockfile key?
A cache is safe with a key that only changes when its inputs change, as long as its contents are fully determined by those inputs. Quick sorting rule I now use:
| Cache | Contents determined by | Key style |
|---|---|---|
| npm / pnpm / yarn download cache | lockfile | hashFiles('**/package-lock.json') |
| pip download cache | requirements / lock file | hashFiles('**/requirements*.txt') |
| Turbo / Nx task cache | every source file | github.sha + prefix restore-keys |
| ccache / sccache | every source file | github.sha + prefix restore-keys |
| mypy / pytest / ESLint cache | every source file | github.sha + prefix restore-keys |
| Docker layer cache via a local path | Dockerfile + build context | github.sha + prefix restore-keys |
If you use actions/setup-node with cache: npm, it caches the npm download directory keyed on your lockfile hash, which lands in the safe row. The trap shows up in the caches you wire up by hand.
Why are my PR builds always cold even with the fix?
Cache scope is per branch. A workflow run can restore caches created on its own branch or on the default branch (and for pull requests, the base branch). A cache saved during a PR run is scoped to that PR's merge ref, so no other PR can read it.
If your workflow only runs on pull_request, main never saves anything, and every new PR starts from zero. I had this too, which is part of why week-one numbers looked fine: the original frozen cache had been seeded by a push to main. Make sure the workflow also runs on push to your default branch so there is always a fresh base cache for PRs to fall back on:
on: push: branches: [main] pull_request: How do you check whether your own cache is frozen?
Three checks, about five minutes total:
- Read the post-job step of your last run. If it says "Cache hit occurred on the primary key ... not saving cache" for a build-output cache, it's frozen.
- Look for static keys in your workflows:
grep -rn "key:" .github/workflows/and flag any key with nohashFiles,github.sha, orgithub.run_idin it. - Open the repo's Actions caches page and sort by created date. A build cache with an old creation date but a recent last-used date is the frozen-cache fingerprint.
So why does the GitHub Actions cache never update on a hit?
The GitHub Actions cache never updates on an exact key hit because cache entries are immutable: when key matches an existing entry, actions/cache restores it, sets cache-hit to true, and skips the save step, and the same key can never be written again. Since restores also reset the 7-day eviction clock, a static key like Linux-turbo-v1 can serve the same stale snapshot for weeks. Use lockfile-hash keys for dependency caches, and for incremental build caches use a unique key such as ${{ runner.os }}-turbo-${{ github.sha }} with a restore-keys prefix, so every run restores the newest entry and saves a fresh one.
Written by the developer behind Preterview, an interview prep platform.
