Why Does My GitHub Actions Cache Miss Every Run?

A GitHub Actions cache misses every run for three reasons: the key string changed, the version hash changed, or the entry was written on another branch.

Last verified:

A GitHub Actions cache misses on every run for one of three reasons: the key string is different on every run, the version hash is different because the compression tool or the cached paths changed, or the entry you want was written on a branch this run cannot read. Read the resolved key first, because a key built from a commit SHA, a run ID, a timestamp, or a lockfile that churns on every merge produces a permanent miss that no amount of cache storage will fix.

Answer

The cache action matches on three things at once. Per the WarpBuild caching documentation, an entry is scoped to the key, the version, and the branch. A run restores an entry only when all three line up. Change any one and the lookup returns nothing, and the job pays the full cost of dependency download and compilation again.

Start from the symptom. Each row below points at one of the three scopes.

What you see in the job logCauseWhere to confirm it
cache-hit is false and the restore step downloads nothing, on every runThe key string changes every runExpand the restore step and read the resolved key on two consecutive runs
The restore step downloads an entry and cache-hit is still falseA restore-keys prefix matchDocumented output behavior: cache-hit is true only on an exact key match
Hits on one runner label, misses on another with the same keyThe version hash differsCompare runner OS and architecture between the two jobs
Hits on the default branch, misses on pull requestsBranch scopingCheck which branch wrote the entry
Hits for a week, then goes cold with no workflow changeExpiry after 7 days of last useCovered in how long GitHub Actions cache entries last

Two of these are reporting problems rather than cache problems. A restore-keys match is a real restore that reports cache-hit as false, and a job that reruns its install step on that signal looks like a permanent miss in the timings while the files were already on disk. Read the restore step output before changing the key.

Detail

Every fact below comes from the WarpBuild caching documentation, the setup actions documentation, the public WarpBuilds/cache repository, or the per-minute rates on the WarpBuild pricing page, checked on 2026-08-13.

The key changes on every run

A cache key is a plain string built from GitHub Actions contexts and functions. Three patterns guarantee a miss forever.

A key containing ${{ github.sha }} or ${{ github.run_id }} is unique per run by construction. The save succeeds, the next run computes a different key, and the entry is never read again.

A key containing a date segment rolls once per day. That is deliberate when you want a daily refresh, and it is a surprise when someone added it to force one cache rebuild and left it in.

A key built from hashFiles('**/package-lock.json') behaves correctly and can still miss on every run. The hash is stable only when the lockfile is stable. A repository that regenerates its lockfile during release, writes a build identifier into it, or has a bot bumping dependencies several times a day changes the hash faster than the cache can be reused.

The check takes one minute. Open two consecutive runs of the same workflow on the same branch, expand the restore step in both, and compare the resolved key line. If the strings differ and no dependency changed, the key is the cause.

A prefix match still reports a miss

restore-keys is an ordered list of prefixes. When the primary key finds nothing, the action walks the list in order and restores the most recent entry whose key starts with one of those prefixes. That restore puts real files on disk.

The cache-hit output stays false for that case. It is set to true on an exact match against the primary key alone. Any step guarded with if: steps.<id>.outputs.cache-hit != 'true' therefore reruns after a prefix match, which is the recommended behavior for an install step, because a partial match usually means the lockfile moved and some packages are missing.

The failure mode is guarding an expensive build step the same way. A compile that would have been incremental against the restored artifacts starts from zero on every run because the boolean says miss.

The version hash covers the compression tool and the paths

The version is a hash over the compression tool available on the runner and the path list being cached. Entries with different versions are different entries even when the key strings are identical.

Two consequences show up in real workflows.

Cross-platform restores fail by design. An entry saved on warp-macos-14-arm64-6x cannot be restored on warp-ubuntu-latest-x64-4x. Caching is enabled by default on Linux runners and is not supported on Windows runners.

Editing the path list invalidates everything. A pull request that adds one directory to path changes the version hash, so every entry written before that merge becomes unreachable. The workflow looks unchanged in review and every job goes cold for a full cycle.

The same mechanism explains the container case. A container image without zstd makes the action fall back to gzip, the version no longer matches the entry written with zstd, and the log prints a zstd version: null warning followed by a 404.

Branch scoping

The third scope is the branch. An entry written by a job on a feature branch belongs to that branch. GitHub's cache key matching rules govern which branches can read it, and the practical shape is that pull request branches read entries written on the base branch and cannot read each other.

That produces a clean signal: warm on main, cold on every pull request. The fix is to make sure the workflow that writes the cache runs on pushes to the default branch, so pull requests have a base entry to fall back to through restore-keys.

A corrected workflow

This version fixes all three scopes at once. The key names the tool and the lockfile hash, runner.os and runner.arch keep platforms in separate namespaces, the ladder gives pull requests a fallback, and the save step runs only when the restore came up short.

name: test
on:
  push:
    branches: [main]
  pull_request:

jobs:
  unit-tests:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22

      - name: Restore dependency cache
        id: deps
        uses: WarpBuilds/cache/restore@v1
        with:
          path: |
            ~/.npm
            node_modules
          key: npm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            npm-${{ runner.os }}-${{ runner.arch }}-

      - name: Install dependencies
        if: steps.deps.outputs.cache-hit != 'true'
        run: npm ci

      - name: Save dependency cache
        if: steps.deps.outputs.cache-hit != 'true'
        uses: WarpBuilds/cache/save@v1
        with:
          path: |
            ~/.npm
            node_modules
          key: ${{ steps.deps.outputs.cache-primary-key }}

      - run: npm test

Four properties are worth naming. WarpBuilds/cache@v1 and its restore and save subactions are drop-in replacements for actions/cache@v4, so the migration is the uses line. The cache-primary-key output carries the key the restore step computed, which keeps the save key identical to the restore key without repeating the expression. The save runs only on a miss, so a warm run performs one operation instead of two. And the path list is now something to treat as frozen: any edit to it rolls the version hash for the whole repository.

Most language toolchains need none of this. The WarpBuild forks of the common setup actions carry the cache client already, and they use WarpBuild Cache automatically on WarpBuild runners: WarpBuilds/setup-node@v6, setup-python@v6, setup-go@v6, setup-java@v5, setup-dotnet@v4, [email protected], setup-zig@v2, rust-cache@v2, gradle-actions/setup-gradle@v5, and mise-action@v2. Full inputs are in the setup actions documentation.

      - uses: WarpBuilds/setup-node@v6
        with:
          node-version: 22
          cache: npm

The Node case has its own layout questions, covered in how to cache node_modules in GitHub Actions. Docker layer caches invalidate on a different set of rules and belong to the Docker layer cache invalidation guide.

What a permanent miss costs

Cache work is billed apart from runner minutes, and the rates are free on BYOC:

ItemHosted rateUnitBYOC
Cache storage$0.20per GB-monthFree
Cache write, restore, or list$0.0001per operationFree

Worked monthly model, with illustrative step durations you should replace with your own. Take a repository running 3,000 jobs a month on warp-ubuntu-latest-x64-4x at $0.008 per minute, where the install step takes 4 minutes cold and 30 seconds warm, and the cache entry holds 2 GB.

  • Permanent miss: 3,000 x 4 = 12,000 minutes at $0.008 = $96.00 per month, plus 3,000 write operations at $0.0001 = $0.30, for $96.30.
  • Working cache: 3,000 x 0.5 = 1,500 minutes at $0.008 = $12.00, plus 2 GB at $0.20 = $0.40 of storage, plus 3,200 operations at $0.0001 = $0.32, for $12.72.
  • Difference on the same job count: $83.58 per month, from one key expression.

Rates come from the WarpBuild pricing page, checked on 2026-08-13.

An entry that always misses still bills storage until it expires, which is the argument for fixing the key rather than raising the size. Sizing and eviction are covered in the guide to the GitHub Actions cache size limit.

When the restore step reports a hit and the files are still absent from the working directory, the path list and the working directory disagree. The Action Debugger opens an SSH session on a paused runner so you can list the restored paths directly, which settles that case faster than another round of log reading.

Why does the cache miss when the job runs inside a container?

The restore step needs wget to download the entry and zstd to decompress it. A container image without zstd makes the action fall back to gzip, which prints a zstd version: null warning and then a 404, because the compression tool is part of the version hash and the gzip version never matches the zstd entry. Install wget and zstd in the container image and pass WARPBUILD_RUNNER_VERIFICATION_TOKEN through to the container, as shown in the caching documentation.

Why does the cache step fail to commit on a Docker build?

Failed to commit cache is the documented result of large Docker layers on a small runner. A 2x runner with layers larger than about 5GB hits it reliably. Move the job to a larger label such as warp-ubuntu-latest-x64-16x, or take the layer cache off the runner entirely with a remote Docker builder, which holds a persistent layer cache on a dedicated build VM. Rates for both are on the pricing page.

Does a restore-keys match count as a cache hit?

No. The cache-hit output is true only on an exact match against the primary key. A prefix match through restore-keys restores an entry and still reports false, so a step guarded on cache-hit reruns even though the files are already on disk. That is the right behavior for an install step and the wrong behavior for an incremental build step. Read the restore step log to tell a partial restore from an empty one.

Why can a macOS cache entry not restore on a Linux runner?

The version hash covers the compression tool and the list of cached paths, so an entry saved on warp-macos-14-arm64-6x carries a different version from an entry saved on warp-ubuntu-latest-x64-4x and the two never match. Put runner.os and runner.arch in the key so the mismatch shows up as a clean miss on a separate key instead of a 404 warning inside a shared one. Entry lifetime is covered in how long GitHub Actions cache entries last.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.