GitHub Actions Cache Miss — Why the Cache Never Restores
Diagnose GitHub Actions cache misses caused by unstable keys, branch scoping, and eviction, then design a key and restore-keys strategy that actually hits.
Symptoms
- Cache not found for input keys: Linux-node-<hash>
- Every workflow run reinstalls dependencies even though nothing changed
- The cache saves successfully but a later run still reports a miss
- Caching works on the default branch and fails on pull requests
Possible causes
- The cache key includes github.run_id, github.sha, or another value that changes every run
- The lockfile path in hashFiles does not match the repository layout or runs from the wrong directory
- Caches created on a feature branch are not visible to other branches, and pull request caches are scoped to the merge ref
- The repository exceeded the 10 GB cache limit and older entries were evicted
- The runner image or cache action version changed the cache version salt
Diagnosis steps
Read the key expression in the workflow
grep -n -B3 -A8 'actions/cache' .github/workflows/ci.ymlInterpretation
A key containing run_id, sha, or a timestamp can never hit. The key must depend only on inputs that determine the cached content.
List caches stored for the repository
gh cache list --limit 30Interpretation
Check whether an entry exists for the branch and key prefix. Entries created on another branch or evicted entries will not appear.
Compare the computed hash with the key in the log
git ls-files 'pnpm-lock.yaml' | xargs sha256sumInterpretation
The log prints the resolved key and the hashFiles input. A mismatch means the path is wrong or the lockfile differs between jobs.
Inspect scope and eviction in the run log
gh run view $(gh run list --limit 1 --json databaseId --jq '.[0].databaseId') --log | grep -iE 'cache (restored|saved|not found)|reserve cache'Interpretation
Restored means the key matched. Not found with reserve cache failed usually means scope or quota, while not found with no reserve line means the save step was skipped.
Root cause
The restore key did not exist in the scope available to the job. Cache keys are immutable and scoped by branch, pull request merge ref, and the cache version, so an unstable key, a path mismatch, or a cache created on a different branch produces a miss even when the workflow is configured correctly.
Fix
Build the key from the runner OS, a stable prefix, and hashFiles of the lockfile, and add restore-keys prefixes for partial fallback. Do not include run_id or sha. Save and restore the package manager's real store path, keep caches under the 10 GB limit, and bump a manual version suffix only when intentionally invalidating. For pull requests, expect the default branch cache to be the reusable source and avoid depending on caches written by the PR itself.
Risk note
Caches are immutable once written, so a bad cache entry keeps being restored until the key changes. Caches can contain poisoned artifacts from a branch with write access, and caching build outputs can hide stale compilation. Never cache secrets, tokens, or .env files.
Verify the fix
Run the same job twice on the same branch with no lockfile change. The second run must log Cache restored from key and show a shorter dependency installation step. Confirm the entry in gh cache list and check that a run after a lockfile change misses once, saves, and hits again on the following run.
A cache miss is usually a key problem
Actions cache lookup is an exact match on the primary key, followed by prefix matches from restore-keys. A key that changes on every run cannot match, and a key saved under a different branch scope is invisible even when the string is identical.
- uses: actions/cache@v4
with:
path: ~/.local/share/pnpm/store
key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
hashFiles('pnpm-lock.yaml') hashes the file relative to the workspace. If the project lives in a subdirectory, the path must be apps/web/pnpm-lock.yaml; otherwise hashFiles matches nothing, the hash is empty, and unrelated projects share one meaningless key.
Scope and quota
Caches are scoped by branch and by pull request merge ref. A cache written on feature/x is not restored by main, while a cache written on the default branch is available to pull requests. Caches are immutable: saving the same key again is skipped, so changing cached content requires a new key. The repository limit is 10 GB; entries beyond it are evicted least-recently-used, which is common on monorepos with many OS and Node combinations.
Inspect instead of guessing
gh cache list --limit 30
gh run view 123456789 --log | grep -iE 'cache (restored|saved|not found)|reserve cache'
The run log prints the resolved key and whether the reserve step succeeded. Cache not found plus Failed to reserve cache usually means quota or scope, not a syntax error.
Fix and verify
Use a stable prefix, a lockfile hash, and restore-keys for fallback, and cache the package manager store instead of node_modules. After the change, run the job twice with no dependency change: the second run should restore and skip most of the install work. Then change the lockfile once and confirm the sequence miss, save, hit behaves as expected.