---
title: "Why Your GitHub Actions Cache Never Hits | StarSling"
description: "Diagnose a GitHub Actions cache that always misses or restores without saving time: key mismatches, tool-specific flags, Turborepo config, and cache scope."
url: https://starsling.dev/github-actions/problems/github-actions-cache-not-working
canonicalUrl: https://starsling.dev/github-actions/problems/github-actions-cache-not-working
---

# GitHub Actions cache reports a miss on every run

[GitHub Actions](https://starsling.dev/github-actions) / [Problems](https://starsling.dev/github-actions/problems) / GitHub Actions cache reports a miss on every run

- [How StarSling works](https://starsling.dev/)

Diagnosis mode: hybrid. Last updated: 2026-08-31

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.

## Table of contents

- [Symptoms](#symptoms)
- [How to diagnose it](#how-to-diagnose-it)
- [Likely causes](#likely-causes)
- [Hand it to an agent](#verify)
- [Related pages](#related-pages)
- [Related symptoms](#related-symptoms)
- [Sources](#sources)

<a id="symptoms"></a>

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

<a id="how-to-diagnose-it"></a>

## How to diagnose it

[ci-speedup](https://starsling.dev/ci-speedup) carries the detection logic behind the performance causes below and opens the fix as a reviewable pull request. Install it with `npx skills add starslingdev/skills`, then run `/ci-speedup` in your repository.

To check by hand:

1. 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.
2. If it is a miss, read the `key:` line in the workflow YAML and check whether it is derived from `hashFiles()` over a real lockfile or source input, or from something that changes every run.
3. 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.
4. 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_request` run 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.
5. For Turborepo, check `TURBO_CACHE` and `TURBO_FORCE` env vars in the workflow, then check `turbo.json` for missing `inputs` or `outputs` on the task involved.
6. If the job holds write permissions or elevated secrets, confirm the missing cache write is not deliberate isolation before adding one.

<a id="likely-causes"></a>

## Likely causes

Ordered by how often each one turns out to be the answer. Confirm a cause with its check before you change anything.

### 1. 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.

Confirm it: `grep -A5 'actions/cache' .github/workflows/*.yml | grep 'key:'` and check whether the key contains a `hashFiles(...)` call over the actual lockfile or input files, versus a literal string, a run ID, or a timestamp.

Fix: [Dependency caching](https://starsling.dev/best-practices/github-actions/cache-dependencies) (`ci.cache.dependency-cache`, detection mode static)

### 2. 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.

Confirm it: `grep -A5 'actions/setup-node' .github/workflows/*.yml` for a `cache: npm` line, and separately check the lint/format/typecheck command lines for `--cache` or `--incremental` flags matching a directory that actions/cache actually restores.

Fix: [npm install caching](https://starsling.dev/github-actions/optimizations/optimize-npm-install) (`ci.cache.npm-install`, detection mode static)

### 3. 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:r` means the job only reads a remote cache that some other job must write, and if nothing writes it, every run misses. `TURBO_FORCE: true` disables all caching, local and remote, regardless of what is configured. Even with caching enabled, a task missing `outputs` in turbo.json has nothing to save, and a task missing `inputs` hashes every git-tracked file in the package, so an unrelated change (a README, a test file) invalidates the cache the build task needed.

Confirm it: `grep -rn 'TURBO_CACHE\|TURBO_FORCE' .github/workflows/` and `jq '.tasks // .pipeline | to_entries[] | select(.value.outputs == null or .value.inputs == null) | .key' turbo.json` to find tasks with no outputs or inputs configured. Keep the `// .pipeline` fallback: Turborepo v1 named that block `pipeline` and only v2 renamed it `tasks`, so reading `.tasks` alone errors on every v1 repository.

Fix: [Turborepo cache health](https://starsling.dev/github-actions/optimizations/optimize-turborepo) (`ci.cache.turborepo`, detection mode static)

### 4. 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 a `pull_request` run are scoped to the merge ref (`refs/pull/.../merge`) and cannot be written to the default branch's scope, and that the `pull_request` event is not subject to the read-only restriction that covers `pull_request_target`, `issue_comment` and `workflow_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.

Confirm it: Open the run in the Actions UI, confirm the trigger is `pull_request` from a fork (not `pull_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.

Fix: [Fork PR cold start from a trusted producer](https://starsling.dev/github-actions/optimizations/speed-up-fork-pr-builds) (`ci.cache.fork-pr-cold-start`, detection mode runtime)

### 5. 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_target` workflow 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.

Confirm it: Check whether the job holds elevated permissions (`id-token: write`, `packages: write`, or runs on `pull_request_target`); if so, confirm with whoever configured the workflow whether the missing cache write is intentional isolation before changing it.

Fix: [Cache poisoning prevention](https://starsling.dev/best-practices/github-actions/prevent-github-actions-cache-poisoning) (`ci.security.cache-isolation`, detection mode static)

<a id="verify"></a>

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

<a id="related-pages"></a>

## Related pages

- [GitHub Actions cache: dependencies, keys, and cache hits](https://starsling.dev/best-practices/github-actions/cache-dependencies)
- [Optimize npm installs in GitHub Actions](https://starsling.dev/github-actions/optimizations/optimize-npm-install)
- [Optimize Turborepo caching in GitHub Actions](https://starsling.dev/github-actions/optimizations/optimize-turborepo)
- [Speed up fork pull request builds in GitHub Actions](https://starsling.dev/github-actions/optimizations/speed-up-fork-pr-builds)
- [Prevent GitHub Actions cache poisoning](https://starsling.dev/best-practices/github-actions/prevent-github-actions-cache-poisoning)
- [Why GitHub Actions is slow, and how to fix it](https://starsling.dev/github-actions-too-slow)

<a id="related-symptoms"></a>

## Related symptoms

Other symptoms on this site that share a likely cause with this one. If none of the causes above is yours, one of these is usually the page you wanted.

- [npm install or npm ci taking a large slice of every run](https://starsling.dev/github-actions/problems/slow-npm-install)
- [Why your test job is slower in CI than it is locally](https://starsling.dev/github-actions/problems/slow-tests)
- [pnpm install is slow in GitHub Actions](https://starsling.dev/github-actions/problems/slow-pnpm-install)

<a id="sources"></a>

## Sources

- [GitHub Actions: dependency caching reference](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows)
- [GitHub Actions: secure use reference (cache poisoning)](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions)
- [Turborepo docs: caching](https://turborepo.com/docs/crafting-your-repository/caching)
- [actions/setup-node: caching global packages data](https://github.com/actions/setup-node#caching-global-packages-data)
