My type-check step took 38 seconds the week I added caching. Nine weeks later it took 4 minutes 10 seconds, and every single run still printed "Cache restored successfully." The cache was working. It was just restoring the same snapshot from week one, over and over, because the GitHub Actions cache never updates once a key gets an exact hit.
Nobody broke anything. That's the annoying part. It's documented behavior, it logs one quiet line about it, and the line looks like good news.
TL;DR
-
actions/cacheonly saves when the primary key missed. On an exact hit, the post step logs "Cache hit occurred on the primary key ..., not saving cache" and uploads nothing. - Cache entries are immutable. You cannot overwrite a key. To refresh it you change the key or delete the entry (
gh cache delete). - For caches that grow every run (mypy, pytest, Next.js
.next/cache, Gradle, ccache), put something unique in the key like${{ github.run_id }}and userestore-keysfor a prefix match. - By default the save happens only if the job succeeds. Use
actions/cache/restore+actions/cache/savewithif: always()if you want failed runs to save too. - Caches are branch-scoped: a PR can read
main's caches,maincannot read a PR's. Ifmainnever runs the workflow, every PR starts cold.
What did my workflow look like?
Pretty standard. I copied the dependency-caching pattern everyone copies and pointed it at mypy's incremental cache:
- uses: actions/cache@v4
with:
path: .mypy_cache
key: ${{ runner.os }}-mypy-${{ hashFiles('requirements*.txt') }}
- run: mypy src/
The logic in my head: "the cache key changes when dependencies change, so the cache stays fresh." That logic is correct for a pip download cache. It is completely wrong for .mypy_cache.
The pip cache depends on the lockfile. The mypy cache depends on my source code, which changes on every commit. My key was tied to the thing that almost never changed, while the contents were tied to the thing that always changed.
Why does the GitHub Actions cache never update?
Because actions/cache treats an exact key hit as "nothing to do." The action runs twice per job: a restore step where you put it, and a post step at the end of the job. The post step checks whether the restore found an exact match on key. If it did, it skips the upload entirely.
Here's the line I scrolled past roughly 600 times:
Post job cleanup.
Cache hit occurred on the primary key Linux-mypy-3f9a..., not saving cache.
So the timeline went like this:
- Run 1: key misses. mypy builds a cold cache. Post step saves
.mypy_cacheunderLinux-mypy-3f9a. - Run 2: key hits exactly. Week-one cache restored. mypy re-checks whatever changed since then. Post step: hit, not saving.
- Run 600: key still hits exactly. Week-one cache restored. mypy re-checks nine weeks of changes on every run. Post step: hit, not saving.
The cache didn't get stale in one jump. It drifted a little further from HEAD with every merged PR, which is why the slowdown looked like "the codebase is getting bigger" instead of "something is broken."
Can you overwrite an existing GitHub Actions cache key?
No. Cache entries are immutable. Once Linux-mypy-3f9a exists, no workflow can write a new version under that exact key. Even if you forced the save step, it would fail with a "cache already exists" style error rather than replace it.
Your only options are:
- Make the key different (new hash, new suffix, new version prefix).
- Delete the old entry, then let the next run miss and save.
Deleting is a decent one-off fix with the GitHub CLI:
gh cache list --key Linux-mypy-
gh cache delete Linux-mypy-3f9a...
But deleting by hand just resets the clock. Nine weeks later you're back here.
How do you make actions/cache save on every run?
Put something unique in key so it misses every time, and let restore-keys pick up the most recent cache by prefix. This is the fix:
- uses: actions/cache@v4
with:
path: .mypy_cache
key: ${{ runner.os }}-mypy-${{ hashFiles('requirements*.txt') }}-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-mypy-${{ hashFiles('requirements*.txt') }}-
${{ runner.os }}-mypy-
What happens now:
- The primary key includes
run_id, so it never matches exactly. -
restore-keysare tried in order as prefixes. When several entries match a prefix, the most recently created one wins. - Because the primary key missed, the post step saves a fresh snapshot under the new unique key.
Each run starts from the previous run's cache and leaves a newer one behind. My mypy step went back to 30 to 40 seconds on the first run after the change.
The second restore key is a fallback for when dependencies change: you still get the old mypy cache instead of starting cold. For mypy that's fine since it invalidates per module. For a tool that gets confused by a stale cache, drop the broad fallback.
Doesn't saving every run blow up cache storage?
It uses more, yes, and GitHub handles it with eviction. Entries that haven't been accessed in 7 days are removed, and when a repo goes over its cache storage limit (10 GB by default), the oldest entries get evicted first. With a run_id key, old snapshots age out on their own.
Two rules I follow now:
-
Dependency caches (pip, npm, Go modules, Cargo registry): key on the lockfile hash, no
run_id. They should only change when the lockfile changes, and an exact hit is exactly what you want. -
Build and analysis caches (mypy, pytest, ruff, ESLint,
.next/cache, Gradle build cache, ccache, sccache): key withrun_idplusrestore-keys. Their contents follow your source, not your lockfile.
If a build cache is huge (multi-GB Gradle or ccache), saving it on every run can push your dependency caches out of the repo budget. In that case save only on main, which the next section makes easy.
Why didn't my failed runs save anything?
Because the built-in post step only runs when the job succeeds. If tests fail after mypy finished its work, that work is thrown away. On a flaky suite this means the cache only refreshes on the lucky green runs.
The split actions give you control:
- uses: actions/cache/restore@v4
id: mypy-cache
with:
path: .mypy_cache
key: ${{ runner.os }}-mypy-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-mypy-
- run: mypy src/
- run: pytest
- uses: actions/cache/save@v4
if: always() && github.ref == 'refs/heads/main'
with:
path: .mypy_cache
key: ${{ steps.mypy-cache.outputs.cache-primary-key }}
if: always() saves even when pytest fails. The github.ref check restricts saving to main, which brings me to the part that confused me second.
Why can't my pull requests see each other's caches?
Caches are scoped to the branch that created them. A workflow run can restore caches created on its own branch and on the base branch (usually main). It cannot restore caches from sibling branches, and main cannot restore caches created by a PR.
That has a consequence people miss: if your workflow only runs on pull_request, main never has a cache, and every new PR starts cold. Each PR builds a cache that only that PR can use.
Fix it by also running on pushes to main:
on:
pull_request:
push:
branches: [main]
Now merges populate a main cache, and every new PR restores from it on its first run.
Which other cache traps look exactly the same?
Three more that produced the same "restored successfully, still slow" symptom for me or for people I've helped:
-
hashFilesmatched nothing. If the glob doesn't match any file,hashFilesreturns an empty string. Your key becomes the constantLinux-pip-, and it hits forever. Check that the key in the log actually ends with a hash. -
setup-node/setup-pythoncache:option. It's convenient, and it keys on your lockfile. It's meant for the package manager's download cache, which is right. Don't expect it to cachenode_modulesor build output. -
Bumping
@v3to@v4and expecting a refresh. The cache entries live in the repo, not in the action version. Same key, same frozen entry.
A quick way to catch all of these: open any green run, expand the post-cache step, and read the last line. "not saving cache" on a build cache means you're living in the past.
So why does the GitHub Actions cache never update?
The GitHub Actions cache never updates because actions/cache skips the save step whenever the primary key gets an exact hit, and cache keys are immutable, so an entry written on day one is restored unchanged forever. That's correct for dependency caches keyed on a lockfile hash. For build and analysis caches whose contents follow your source code, add ${{ github.run_id }} to the key, use restore-keys with a prefix so each run starts from the newest snapshot, save with actions/cache/save and if: always() if failed runs should count, and run the workflow on pushes to main so pull requests have a warm cache to start from.
Written by the developer behind Preterview, an interview prep platform.
Top comments (0)