GitHub Actions cache reports a miss on every run
A cache that never hits usually means the key never matches what was saved, most often because it is not derived from a lockfile hash, or because the tool that owns the cached directory needs its own flag to actually use it. Check the cache step's log line for a hit or miss first, then the key expression, before assuming caching is broken.
AI agents find the cause
StarSling agents run this diagnosis against your workflows and run history, identify which cause is yours, and open the fix as a reviewable PR.
Symptoms
In a GitHub Actions run, actions/cache (or a setup action wrapping it) compares the key you gave it against what previously saved under that key, restores the closest match it finds, and writes a new entry under the current key when the job finishes. A reported miss means no key matched, and that is almost always a key problem rather than a platform problem: an unstable key, a lockfile that changed, a job on a branch whose scope has nothing saved yet, or an ecosystem-specific cache (Turborepo's remote cache, npm's registry cache) with its own separate rules. A restore that succeeds but does not save time is a different failure: the files came back, but the command that runs afterward does not know how to use them, because the tool itself was never told to read its own incremental cache. The order below follows how often each cause explains the symptom: the general dependency cache first, because most jobs hit it before anything ecosystem-specific; then npm's cache and Turborepo's cache, which have their own key and mode rules on top of the general mechanism; then the structural case of fork pull requests, whose cache writes are confined to their own merge-ref scope by design; and finally the case where a cache is deliberately not shared across a trust boundary, which looks identical to a bug but is not one.
The actions/cache step's log reads "Cache not found for input keys" on a run where the lockfile or source tree has not changed since the previous run.
The cache step reports a hit, but the install or build command that follows still takes as long as a cold run.
A feature branch never gets a cache hit even though the same job has been cached and green on the default branch for weeks.
Every new fork pull request shows a cold install or build, even though earlier fork pull requests have run the same workflow against the same repository.
Total job time crept back up over time even though nobody changed the cache configuration.
How to diagnose it
Open the most recent run in the Actions UI and read the cache step's log line directly. Either action prints "Cache restored from key" on a hit, but the miss is worded per action: actions/cache prints "Cache not found for input keys", while actions/setup-node prints "npm cache is not found" (or
pnpm/yarn). Grep for the string the job's own action emits before concluding it missed.If it is a miss, read the
key:line in the workflow YAML and check whether it is derived fromhashFiles()over a real lockfile or source input, or from something that changes every run.If it is a hit, check whether the command that follows the cache step actually points at the restored directory, and whether that tool has its own cache or incremental flag it needs to be passed.
Check which branch and trigger produced the run, and tell the two cold cases apart. A first run on a new branch misses once and then warms its own scope. A
pull_requestrun from a fork can restore from the base branch's scope, and it writes only to its own merge-ref scope, so the FIRST run of each fork PR is cold unless the base branch already holds an entry under a key the fork job can compute - that is cause 4, and it has a fix.For Turborepo, check
TURBO_CACHEandTURBO_FORCEenv vars in the workflow, then checkturbo.jsonfor missinginputsoroutputson the task involved.If the job holds write permissions or elevated secrets, confirm the missing cache write is not deliberate isolation before adding one.
Likely causes
Ordered by how often each one turns out to be the answer. Confirm a cause with its check before you change anything.
The cache key never matches what was saved
actions/cache resolves key against existing entries: an exact match is a hit, a miss falls through to restore-keys for a prefix match, and if nothing matches at all a fresh cache is written under the new key when the job succeeds. A key that embeds something that changes every run, a timestamp, a run ID, a random value, can never repeat, so every run misses and writes a new entry the next run also misses. A key that is broader than the directory it protects has the opposite problem: a single unrelated file change invalidates a much larger cache than it needs to. And a job that installs a large binary, a browser, an SDK, a toolchain, without any cache step at all will read as a permanent miss because there is nothing to compare against.
Fix: Dependency cachingConfirm it
grep -A5 'actions/cache' .github/workflows/*.yml | grep 'key:'and check whether the key contains ahashFiles(...)call over the actual lockfile or input files, versus a literal string, a run ID, or a timestamp.The install itself never reads the restored cache
actions/setup-node's built-in cache input wraps actions/cache around npm's own package cache and keys it on the lockfile automatically, but only when a workflow sets
cache: npm. Without it, npm has nothing local to resolve against and refetches every package over the network regardless of what else is cached. A separate but similar failure: a linter, formatter, or type-checker with its own incremental cache flag (Prettier's--cache, ESLint's--cache,tsc --incremental) can have its cache directory correctly restored by actions/cache and still redo all the work, because the tool was never invoked with the flag that tells it to use that directory.Fix: npm install cachingConfirm it
grep -A5 'actions/setup-node' .github/workflows/*.ymlfor acache: npmline, and separately check the lint/format/typecheck command lines for--cacheor--incrementalflags matching a directory that actions/cache actually restores.Turborepo's cache is set to read-only, forced off, or missing inputs/outputs
Turborepo has its own caching layer on top of actions/cache:
TURBO_CACHE: remote:rmeans the job only reads a remote cache that some other job must write, and if nothing writes it, every run misses.TURBO_FORCE: truedisables all caching, local and remote, regardless of what is configured. Even with caching enabled, a task missingoutputsin turbo.json has nothing to save, and a task missinginputshashes every git-tracked file in the package, so an unrelated change (a README, a test file) invalidates the cache the build task needed.Fix: Turborepo cache healthConfirm it
grep -rn 'TURBO_CACHE\|TURBO_FORCE' .github/workflows/andjq '.tasks // .pipeline | to_entries[] | select(.value.outputs == null or .value.inputs == null) | .key' turbo.jsonto find tasks with no outputs or inputs configured. Keep the// .pipelinefallback: Turborepo v1 named that blockpipelineand only v2 renamed ittasks, so reading.tasksalone errors on every v1 repository.The job runs on a fork pull request, which writes only to that pull request's own cache scope
Cache access on GitHub Actions is directional. A run triggered by
pull_request, including from a fork, can restore caches created in the base branch, and it does write: GitHub's caching reference states that caches created by apull_requestrun are scoped to the merge ref (refs/pull/.../merge) and cannot be written to the default branch's scope, and that thepull_requestevent is not subject to the read-only restriction that coverspull_request_target,issue_commentandworkflow_run. So a later run on the SAME pull request can restore what an earlier run on it saved, while a different pull request starts from the base branch's entries or from nothing. The first run of each fork PR is cold unless a trusted workflow on the base branch has already saved an entry under a key the fork job can compute. The read-only token and absent secrets come from the same trust boundary, but they are not what makes that first run cold; the empty per-PR scope is. The fix works with the boundary: a trusted producer job publishes the warm cache or artifact, and the fork job restores it read-only with a local fallback so the PR still completes on a miss.Fix: Fork PR cold start from a trusted producerConfirm it
Open the run in the Actions UI, confirm the trigger is
pull_requestfrom a fork (notpull_request_target), and check whether the same job succeeds with a cache hit when triggered from a branch in the base repository instead of a fork.The cache is deliberately not shared across a trust boundary
GitHub restricts which triggers can write to the default branch's cache scope specifically because a workflow that can be influenced by an outside contributor, a fork pull request, an issue comment, could otherwise write a cache entry that a later, more privileged workflow restores and trusts: cache poisoning. If a release job or a
pull_request_targetworkflow was deliberately kept off a shared cache, or restricted to read-only restoration, that is a security boundary working as intended, not a bug to route around by widening cache sharing.Fix: Cache poisoning preventionConfirm it
Check whether the job holds elevated permissions (
id-token: write,packages: write, or runs onpull_request_target); if so, confirm with whoever configured the workflow whether the missing cache write is intentional isolation before changing it.
Hand it to an agent
Hand this prompt to your coding agent (Claude Code, Cursor, and the like) to run this diagnosis against your repository and report which cause it found.
Diagnose why a GitHub Actions cache step reports a miss on every run, or restores without saving time, in this repository's workflows. Do not change anything yet; report findings first.
1. Find every `actions/cache` step (or setup action with a `cache:` input) and note its `key`, `restore-keys`, and the workflow's trigger.
2. Check whether `key` is derived from `hashFiles()` over a real lockfile, versus a literal string, timestamp, or run ID. An unstable key is cause 1.
3. Check whether `actions/setup-node` sets `cache: npm`, and whether lint/format/typecheck commands use their own `--cache` or `--incremental` flag. A missing flag is cause 2.
4. If Turborepo is used, check `TURBO_CACHE`/`TURBO_FORCE` env vars and `inputs`/`outputs` in turbo.json. That is cause 3.
5. If the job runs on `pull_request` from a fork, that job can restore the base branch's cache and writes only to its own merge-ref scope, so the first run of each pull request is cold until a trusted workflow on the base branch saves an entry under a key the fork job can compute. That is cause 4; report it rather than stopping at "expected".
6. If the job holds elevated permissions or runs on `pull_request_target`, a missing cache write may be deliberate isolation (cause 5); ask before adding one.
Report which cause you found, with file and job names, before making any change.Go further
Find the cause, fix them all, or keep them fixed.
You have the prompt that works out which cause applies. Here is how much further you can take it, each step doing more for you than the last.
Find the cause
Copy the diagnosis prompt above
Hand it to your coding agent to confirm which cause applies in your repo. It reports back what it found and changes nothing.
Fix them all, once
Install the ci-speedup skill
One prompt audits your whole repo against all 73 ci-speedup patterns (these 14 plus 59 more) and hands your agent every fix at once. Open source, MIT, runs locally.
Keep it fixed, forever
Install the StarSling GitHub App
Connect GitHub and the fixes stay applied as your CI evolves, with agents that keep inspecting your workflows and opening optimization PRs you review.
Sources
1GitHub Actions: dependency caching reference (opens in new tab)
2GitHub Actions: secure use reference (cache poisoning) (opens in new tab)
3Turborepo docs: caching (opens in new tab)
4actions/setup-node: caching global packages data (opens in new tab)
Last updated 2026-08-31