DEV Community

jidonglab
jidonglab

Posted on

Your GitHub Actions Cache Misses on Every PR: The Branch Scope Rule

Our CI was fast on main and slow on every pull request. Same workflow file. Same runner. Same lockfile. On main, the install step finished in seconds. On a PR, the log said:

Cache not found for input keys: node-modules-Linux-8f2c...
Enter fullscreen mode Exit fullscreen mode

I spent an embarrassing afternoon rewriting the cache key before I understood the actual rule: the GitHub Actions cache misses on every PR not because your key is wrong, but because caches are scoped to git refs, and your branch is not allowed to read another branch's cache.

TL;DR

  • GitHub Actions caches are scoped by git ref. A run can restore caches created on its own ref, on the base branch of its PR, and on the repository's default branch. Nothing else.
  • Sibling feature branches can never share a cache. Branch A's cache is invisible to branch B, forever, no matter how identical the key is.
  • A pull_request run writes its cache to refs/pull/N/merge, so the first run of a PR is always a cold miss unless the default branch has a matching cache.
  • The fix is one workflow: populate the cache on the default branch (on push and on a schedule) so every PR inherits a warm one via restore-keys.
  • Caches are immutable. Saving the same key twice does nothing. The repo cap is 10 GB with LRU eviction, and any cache untouched for 7 days is deleted.

Why does my GitHub Actions cache miss on every PR?

Because cache reads are governed by ref-based access control, not by the key. actions/cache computes your key, asks the cache service for it, and the service only searches the scopes your run is allowed to see:

  1. The current ref (refs/heads/my-branch, or refs/pull/42/merge for a pull_request run).
  2. The base ref of the pull request.
  3. The repository's default branch.

That's the whole list. If you built the cache while working on feat/payments and you now open feat/search, the entry exists, it is byte-identical to what you need, and your run cannot touch it. GitHub does this deliberately: a cache is executable content in disguise, and letting any branch poison a cache that any other branch restores would be a supply-chain hole with a friendly YAML interface.

The same isolation applies to forks, harder. A pull_request run from a forked repository can restore from the base repository's default branch, but anything it writes stays inside that PR's scope and never becomes visible to your branches. Which is exactly what you want, and also why "the cache works for the team but not for outside contributors" is not a bug report.

How do I prove the branch scope rule in my own repo?

Ask the API which ref owns each cache. The REST endpoint returns a ref field that gh cache list mostly hides from you:

gh api /repos/{owner}/{repo}/actions/caches \
  --jq '.actions_caches[] | "\(.ref)\t\(.key)"'
Enter fullscreen mode Exit fullscreen mode

You'll get something like:

refs/heads/main            node-modules-Linux-8f2c1a
refs/pull/41/merge         node-modules-Linux-8f2c1a
refs/pull/42/merge         node-modules-Linux-3d9be7
Enter fullscreen mode Exit fullscreen mode

Three entries, two of them identical keys. That is the tell. If the same key appears under multiple refs, you are not sharing a cache, you are paying to store the same tarball once per branch, and every new PR starts from zero.

Reproduce it in five minutes:

  1. On main, run a workflow that saves a cache with key demo-1.
  2. Branch off main, change the key to demo-2, push, let it save.
  3. Create a second branch from main, set the key back to demo-2, push.

The third run misses. Same key, same repo, same runner image. The only difference is the ref that created the entry.

How do I fix GitHub Actions cache misses on pull requests?

Warm the default branch. Every PR is allowed to read the default branch's caches, so if main always holds a fresh entry, every PR gets at least a restore-keys hit on its first run.

name: warm-cache
on:
  push:
    branches: [main]
  schedule:
    - cron: '0 5 * * *'

jobs:
  warm:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - name: Restore deps
        id: cache
        uses: actions/cache@v4
        with:
          path: node_modules
          key: node-modules-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
          restore-keys: |
            node-modules-${{ runner.os }}-
      - run: npm ci
        if: steps.cache.outputs.cache-hit != 'true'
Enter fullscreen mode Exit fullscreen mode

Two details do the work.

The schedule trigger. Caches are evicted after 7 days without access. A weekly-ish repo will silently go cold, and you'll blame the runner. A daily cron keeps the default-branch entry alive and its access timestamp fresh.

The trailing-dash restore-keys. Your exact key contains a lockfile hash, so it changes the moment anyone touches package-lock.json. The prefix key node-modules-Linux- matches the most recent entry in scope and restores it as a base layer. npm ci then reconciles the delta instead of downloading the world.

What else about the Actions cache surprises people?

Caches are immutable. You cannot overwrite a key. Save demo-1 today with a broken node_modules and every future run in that scope restores the broken one until the key changes or the entry is evicted. That's why hashing the lockfile into the key is not a nicety, it is the only invalidation mechanism you have. Manual eviction:

gh cache delete node-modules-Linux-8f2c1a
Enter fullscreen mode Exit fullscreen mode

Parallel matrix jobs fight over one key. Run four matrix legs with an identical key and three of them log Unable to reserve cache with key ..., another job may be creating this cache. Not an error, just wasted work. Put the matrix variable in the key.

cache-hit is false on a restore-keys hit. It is only true for an exact key match. A partial restore still populates the path, but the output says false. So if: cache-hit != 'true' (re-run the install) is correct, and any step that assumes a false value means "the directory is empty" will happily run against dependencies from someone else's lockfile.

The 10 GB repo cap is LRU. One team caching a fat Docker layer per branch will quietly evict everyone else's node_modules. Cache misses that appear across unrelated workflows on the same repo are usually this, not your YAML.

Does setup-node's built-in cache avoid the branch scope rule?

No. actions/setup-node with cache: npm (and the equivalents in setup-python, setup-java, setup-go) calls the same cache service with the same ref-scoping. It is more convenient and it caches the package manager's download store rather than node_modules, which is often the better trade. But it is subject to identical rules: no cross-branch reads, 7-day eviction, immutable keys, 10 GB cap. If your PRs are cold with actions/cache, they are cold with setup-node too. The warm-the-default-branch fix is the same either way.

The short answer

Your GitHub Actions cache misses on every PR because cache entries are scoped to the git ref that created them, and a run may only restore from its own ref, its PR's base ref, and the repository's default branch. A cache built on one feature branch is permanently invisible to every sibling branch, and a pull_request run stores its cache under refs/pull/N/merge, so a brand-new PR has nothing of its own to restore. Fix it by keeping a fresh entry on the default branch with a push-plus-schedule workflow and a prefix restore-keys fallback, then verify with gh api /repos/{owner}/{repo}/actions/caches that you have one shared entry on main instead of a duplicate per branch.


Written by the developer behind Preterview, an interview prep platform.

Top comments (0)