← Debugging Guides

阅读中文版本

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.

beginnerdebugginggithub-actionsci-cdcaching

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.yml

Interpretation

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 30

Interpretation

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 sha256sum

Interpretation

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.