A GitHub Actions cache reports cache-hit true but builds use stale dependencies. Why?
answer
- an exact hit every single run is the clue
- the key contains nothing that ever changes
- entries cannot be replaced under the same name
- hash the lockfile, not the manifest
- a version segment in the key is the escape hatch
basics
~20 sThe actions/cache key contains nothing that changes when dependencies change, so it matches forever. Cache entries are immutable, so the first archive saved under that key is served indefinitely and no new one can replace it.
solid answer
~50 sAn exact `cache-hit` every run means the key is constant — something like `node-modules` or a key built only from `runner.os` and the branch. Because entries are immutable, the archive saved the first time is returned for every later run and can never be refreshed under that name. If the job also skips its install step on `cache-hit == 'true'`, the build proceeds against whatever dependency tree was captured months ago. The fix is to put a fingerprint of the inputs in the key, typically `hashFiles('**/package-lock.json')` or the equivalent lockfile glob, keep the old constant part as a `restore-key` so you still get a warm start, and cache the package manager's download directory while always running the install rather than caching the installed tree and skipping it. If you must recover immediately, bump a version segment in the key or delete the entry.
code
yaml · 17 lines# broken: matches forever, entry is immutable, install skipped on the hit
- uses: actions/cache@v4
id: bad
with:
path: node_modules
key: build-cache
- if: steps.bad.outputs.cache-hit != 'true'
run: npm ci
# fixed: key moves with the lockfile, prefix keeps the warm start, install always runs
- uses: actions/cache@v4
with:
path: ~/.npm
key: v2-${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
v2-${{ runner.os }}-npm-
- run: npm cigo deeper
Recognise that a cache key with nothing variable in it will match forever, and that a lockfile hash is what normally makes the key change.
Connect the two mechanics: exact-match cache-hit plus immutable entries mean the first archive is frozen under that key, and describe the hashed-key-plus-restore-key fix.
Diagnose from the logs — same key across runs, install skipped on a hit — and explain the safe design: cache the download directory, always install, include OS and matrix values, and recover via a key version bump.
Own cache hygiene as a standard: naming conventions with a version segment, who seeds the default-branch cache, budget and eviction awareness, and the rule that no pipeline may depend on a cache for correctness.
## Reading the symptom Two facts combine to produce this failure. First, `cache-hit` is `'true'` only on an exact key match, so seeing it every run tells you the key does not vary with content. Second, GitHub Actions cache entries are **immutable**: a save under an existing key is a no-op. Together they mean the very first archive stored under that key is what every subsequent run receives, forever, no matter how the repository changes. The damage depends on what was cached and what the job does with the hit: - Caching a package manager's **download directory** and still running the install is mostly harmless — the install reconciles anything missing, just with less benefit than it should have. - Caching an **installed tree** (`node_modules`, a virtualenv, a vendored directory) and skipping the install on a hit is the damaging version: the job runs against the wrong dependency versions, tests pass or fail for reasons unrelated to the change, and a security bump merged weeks ago is not actually in the build. ## The fix, in order **1. Make the key a fingerprint.** Hash the files that determine the content: key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-npm- `hashFiles` computes a SHA-256 over the matched files; when the lockfile changes, the key changes, the exact lookup misses, the prefix restores the previous archive as a warm start, and the post step saves a fresh entry under the new key. Hash the **lockfile**, not the manifest: the manifest can declare a range that resolves differently over time. **2. Do not skip the install on a hit.** Prefer caching the download or wrapper directory and running the install unconditionally. It is fast against a warm cache, and it makes a stale cache a performance issue rather than a correctness one. **3. Include everything that varies.** Runner OS, language or toolchain version, and — for a matrix — the matrix values that affect the resolved dependency set. A single key shared across matrix legs makes each leg overwrite nothing and restore something wrong. **4. Recover the existing bad entry.** Because you cannot overwrite it, either change the key (a `v2-` segment is the usual trick, and it also invalidates the whole family cleanly) or delete the entry through the repository's cache management UI or API. ## Related failure modes to name - **The mirror image: a key that never hits.** Putting `github.sha` or `github.run_id` in the key gives a unique key every run — every run misses, saves a new entry, and the repository's cache budget is consumed by write-once garbage. If every run reports a miss, look for a per-run value in the key. - **Branch scoping mistaken for staleness.** Caches are visible to the branch that created them, to a pull request's base branch, and from the repository's default branch. A feature branch cannot see a sibling branch's cache, so a first run on a new branch legitimately misses even with a perfect key. Seeding the default branch is what makes the fallback warm for everyone. - **Eviction.** Entries expire after a period without use, and the repository has a total cache-size budget with least-recently-used eviction. A cache that used to hit and now does not may simply have been evicted — always keep the pipeline correct without a cache. - **Cross-OS restores.** Without `runner.os` in the key, a Windows leg can restore a Linux archive and fail in confusing ways. ## How to investigate Read the cache step's log lines: they report the key searched, whether the match was exact or by restore-key, the entry's size, and whether the post step saved. Compare the key printed on two runs across a dependency change — if it is byte-identical, the key is the bug. Then check what the install step did: an install skipped by an `if:` on `cache-hit` turns a cache miss-design into a wrong build. ## The answer in one breath "Constant key plus immutable entries equals a permanently frozen cache. Hash the lockfile into the key, keep the constant part as a restore-key, and never skip the install on a hit."
- How do you recover when a bad archive is already stored under a key you cannot overwrite?Change the key so a new entry is created — conventionally by adding or bumping a version segment such as `v2-` in the prefix, which invalidates the whole family including restore-key fallbacks — or delete the entry through the repository's cache management UI or API. There is no way to refresh an entry in place.
- What does it mean if every run reports a cache miss instead?Usually a per-run value in the key, such as `github.sha` or `github.run_id`: the key is unique every time, so nothing ever matches and each run writes a fresh entry that consumes the repository's cache budget. Other causes are a first run on a branch that cannot see a sibling's caches, or eviction of an unused entry.
- Why hash the lockfile rather than the manifest file?A manifest declares version ranges, so the same manifest can resolve to different dependency versions on different days; hashing it produces a key that fails to change when the actual dependency set does. The lockfile pins exact resolved versions, making its hash a true fingerprint of what will be installed.
saying these in an interview costs you the question
- Blames the cache service rather than the key
- Tries to overwrite the entry under the same key
- Hashes the manifest instead of the lockfile
- Skips the install whenever cache-hit is true
- Caches an installed tree and treats it as authoritative