Why GitHub Actions Cache Never Hits: The Wrong-File Hash and Fork-PR Traps
You added a cache step, watched the first run save a few hundred megabytes of dependencies, and felt good about it. Then every run since has printed Cache not found for input keys and rebuilt everything from scratch. The key looks correct. You copied it from the documentation. And still, nothing.
A cache that never hits is worse than no cache at all. It costs you the upload time on every run, it costs you the restore attempt, and it quietly convinces your team that caching on CI is just unreliable. It is not. The symptom is almost always one of two specific, diagnosable causes, and neither of them is fixed by the thing most people reach for, which is stacking more restore-keys on top.
The rule I follow: never tune a cache key you have not printed. If you have not seen the exact string your workflow computed on two separate runs, you are guessing.
Two causes produce the same permanent-miss symptom:
- Wrong file hashed.
hashFiles('**/package.json')hashes a file an earlier step regenerates, so the key changes every run and can never match. Hash the lockfile (package-lock.json,poetry.lock,go.sum) instead. - Fork PR. Workflows triggered by a pull request from a fork have read-only cache access. They restore but never save, so external-contributor PRs always look like a miss.
Print the computed key in a debug step and compare it across two runs before touching anything else.
First, prove the key is stable
You cannot debug a cache without seeing the key. The actions/cache action does not print the resolved key by default, so add a step that does, immediately before the cache step:
- name: Debug cache key
run: |
echo "computed key: node-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}"
- name: Cache node modules
uses: actions/cache@v4
with:
path: ~/.npm
key: node-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
node-${{ runner.os }}-
Run the workflow twice without changing any dependency. Open both logs and compare the computed key line. There are only two outcomes, and they point at two completely different bugs.
Cause one: the key changes every run
If the printed key differs between two runs with identical dependencies, you are hashing a file that is not stable across runs. The classic example in the JavaScript world:
# Broken: package.json is often rewritten during the run
key: node-${{ runner.os }}-${{ hashFiles('**/package.json') }}
Here is the mechanism people miss. hashFiles() computes a SHA-256 over the file contents at the moment the expression is evaluated. If any earlier step in the job rewrites package.json (a version bump step, a codegen tool, npm version, a monorepo tool that normalises the manifest, even a formatter), the bytes change, the hash changes, and your key becomes unique to that run. A unique key can match nothing, so it saves a fresh cache every time and restores nothing, forever.
The fix is to hash the file that is actually stable: the lockfile. A lockfile only changes when your resolved dependency tree changes, which is exactly the invalidation you want.
# Correct: the lockfile only changes when dependencies change
key: node-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
The same trap exists in every ecosystem. Hash poetry.lock or requirements.txt, not your Python source. Hash go.sum, not go.mod if a tooling step rewrites go.mod. Hash Cargo.lock, not Cargo.toml. If you use the language-specific setup actions, prefer their built-in caching (actions/setup-node with cache: npm) because it already keys on the lockfile for you.
A subtler version: hashFiles('**/package-lock.json') with the double-star glob will hash every lockfile in the repo, including generated ones in build output if they exist at evaluation time. In a monorepo, be explicit about which lockfiles define the cache, for example hashFiles('package-lock.json', 'apps/*/package-lock.json').
Cause two: the key matches but nothing was ever saved
If the printed key is identical across runs and you still get a miss, the problem is not the key. It is that no save ever succeeded to match against. The most common reason in 2026 is cache access scope.
In June 2026 GitHub tightened this: a workflow run triggered by an untrusted event gets a read-only cache token. The canonical untrusted event is a pull_request from a fork, where someone who is not a repository collaborator triggered the run. Such a run can restore an existing cache but cannot write a new one. When the save is blocked, actions/cache logs a warning and the job continues without caching:
Warning: Cache save failed.
Unable to reserve cache with key ..., another job may be creating this cache.
Failed to save: Cache service responded with 403
So a first-time external contributor opens a PR, their run finds no cache to restore (nobody with write access has populated the key their branch produces), and it also cannot save one. Every fork PR looks like a permanent cold cache, while your own push builds on the default branch are warm. That asymmetry is the tell.
The fix is architectural, not a key change. Populate the cache from a trusted, read-write event and let the read-only runs restore it:
on:
push:
branches: [main] # read-write: this run SAVES the cache
pull_request: # read-only for forks: this run only RESTORES
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: ~/.npm
key: node-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
node-${{ runner.os }}-
Now a push to main saves the cache under the lockfile key. Any later fork PR with the same lockfile restores it. The PR still cannot save, but it does not need to, because the push already did. This is also why the restore-keys prefix fallback earns its place here: a PR whose lockfile changed slightly can still fall back to the most recent node-Linux- cache from main and only reinstall the delta.
Verification: prove the hit
The actions/cache action sets an output, cache-hit, that is 'true' only on an exact key match. Assert on it so a silent regression to permanent-miss becomes a visible signal:
- uses: actions/cache@v4
id: npm-cache
with:
path: ~/.npm
key: node-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: node-${{ runner.os }}-
- name: Report cache result
run: echo "exact hit = ${{ steps.npm-cache.outputs.cache-hit }}"
On the run after a successful save with unchanged dependencies, that line must print exact hit = true. If it prints an empty string or false while the key is provably stable, you are back to cause two: check the trigger and the access scope. A partial restore via restore-keys does not set cache-hit to true, which is correct and by design.
What people get wrong
Adding more restore-keys to force a hit. This is the number one wrong fix. restore-keys never produces an exact hit; it produces a partial restore from the newest prefix match, and then the step still tries to save under the primary key. If your primary key is unstable, all the fallback does is restore a stale, unrelated cache and mask the fact that you rebuild every time. Fix the key first. Add fallbacks second, deliberately, for the delta case.
Caching node_modules directly instead of the download cache. Caching the installed node_modules tree couples your cache to the exact OS and Node version and to install-time codegen, and it restores a tree that npm ci would rather rebuild anyway. Cache ~/.npm (or ~/.cache/pip, the pnpm store, the Go build cache) and let the package manager do a fast install from warm downloads. It is more robust and it invalidates correctly.
Assuming every miss is your fault. GitHub's cache service has documented intermittent miss behaviour even with a stable key. If you have verified the key and the access scope and you still see an occasional cold restore, that is the service, not you. Do not chase it into a rewrite. Make the job correct when the cache is cold and treat warmth as an optimisation, never a dependency.
When it is still broken
If the key is stable, the trigger has write access, and you still miss:
- Check the branch scope of the cache. Caches are scoped to the branch that created them, with fallback to the default branch. A cache saved on a feature branch is not visible to an unrelated feature branch. If you need shared caches, populate from
main. - Check eviction. A repository has a 10 GB cache limit and GitHub evicts least-recently-used entries once it is exceeded, and any cache untouched for 7 days is removed. A large, churny cache set can evict your key before the next run reads it. Trim what you cache.
- Check the
actions/cachemajor version. Version bumps have changed cache format and restore behaviour before. If a hit that worked last month stopped after a dependency bump on the action itself, read that version's release notes; a mismatched save and restore version will not cross-match. - Check the path. If
pathpoints somewhere the install does not actually populate, the save writes an empty or wrong directory and the restore looks like a no-op even on a hit.
If you are running CI to deploy to your own box rather than a managed platform, the same caching discipline applies; I go through the full pipeline in shipping to your own VPS with GitHub Actions. Get the key stable, get the trigger right, and the cache stops being a mystery.
Frequently asked questions
- Why does my GitHub Actions cache miss on every run even though the key looks right?
- The most common cause is that hashFiles() is hashing a file that changes every run, so the key is different each time and can never match a saved cache. Print the computed key in a debug step across two identical runs. If it differs, you are hashing a generated or reformatted file (often package.json) instead of a stable lockfile like package-lock.json.
- Can pull requests from a fork save a cache?
- No. Workflows triggered by a fork have read-only cache access. They can restore an existing cache but cannot save a new one, so a first-time external contributor's PR always looks like a permanent miss. Move cache population to a push-triggered workflow on your default branch, which has read-write access.
- Should I add more restore-keys to fix a cache that never hits?
- Not first. restore-keys is a prefix-match fallback that hides a broken primary key rather than fixing it. Confirm the exact-match key is computed consistently across runs before adding fallbacks, otherwise you are just papering over the real bug and will still rebuild dependencies you thought were cached.
- Is a cache miss always my configuration's fault?
- No. GitHub's cache service has documented intermittent miss behaviour even with a correct, stable key. If your key is verified stable and you still see occasional misses, that is a known service characteristic, not your workflow. Design the job to tolerate a miss gracefully rather than assuming every miss is a bug you can fix.