How cache keys control exact matches
A cache key is an explicit string that identifies a cache entry. You build the key from context values such as runner.os, hashFiles of a lockfile, or commit SHA. When the key matches an existing entry exactly, the action restores the cached files and sets the cache-hit output to true. Subsequent steps can then skip install commands. If the key does not match, the step reports a miss and the workflow continues to the next restore-keys entry or creates a fresh cache at the end of the job.
- Use hashFiles('**/package-lock.json') to tie the key to the exact set of dependencies.
- Add runner.os or runner.arch when the same lockfile produces different artifacts on different platforms.
- Avoid github.run_id or github.sha in the primary key unless you intend a new cache on every push.
Restore keys provide fallback matches
Restore keys are an ordered list of prefix strings. When the primary key misses, the action tries each restore key in order and restores the most recent cache whose key begins with the prefix. This mechanism supplies a usable set of dependencies even after a lockfile change, so only the delta needs to be downloaded. The restored cache is still read-only; a new cache is saved only under the original primary key if the job succeeds.
- List the most specific prefix first, then broader prefixes.
- A typical pattern is cache-npm-${{ hashFiles('**/package-lock.json') }} followed by cache-npm-.
- The action returns the matched key in the outputs so later steps can decide whether to run install commands.
Cache invalidation and updates
Because every cache entry is immutable, changing the primary key is the only way to create a new cache. When a lockfile hash changes, the next run uses a new key and saves a fresh cache. If you need to refresh a cache on every commit, generate a unique key with github.run_id and rely on restore keys to pull the previous state. This approach consumes quota quickly but guarantees the latest files are cached. Read-only tokens, common on pull requests from forks, prevent saves while still allowing restores.
- Caches scoped to a feature branch are not visible to the default branch until merged.
- Version mismatches occur when compression settings or runner OS differ; a Windows cache cannot be restored on Ubuntu.
- Use the cache-hit output to exit early or to skip install steps only on exact matches.
Common stale-cache mistakes
Teams often include volatile values such as github.run_number in the primary key, producing a cache miss on every run. Another frequent error is changing the path list between restore and save steps, which silently creates a new cache instead of reusing the intended one. Overly broad restore keys can pull an ancient cache that no longer contains current transitive dependencies, leading to subtle test failures. Finally, relying on a single job to populate caches for the entire repository can hit rate limits or token restrictions when that job runs from a fork.
- Never embed timestamps or run IDs in the primary key unless you deliberately want a fresh cache each time.
- Keep path lists identical in restore and save actions.
- Test restore-key behavior by temporarily deleting the lockfile hash and confirming the fallback cache is restored.
Practical workflow patterns
A common pattern restores with a lockfile hash key and a short restore-keys prefix, then installs only on cache miss. Another pattern uses a centralized cache job that runs on the default branch with write permissions while other jobs use read-only tokens. When lockfiles are generated during the build, compute the key inside the save step rather than the restore step. Always place the cache action before any step that needs the cached files.
- Check steps.restore-cache.outputs.cache-hit != 'true' before running install commands.
- Store the output of the restore action and pass it to the save action to avoid recomputing the key.
- Limit cache size by excluding unnecessary directories such as node_modules/.cache.