What Are restore-keys in GitHub Actions?

restore-keys are ordered prefix fallbacks for a cache miss: the action restores the newest entry whose key starts with the first prefix that matches.

restore-keys is an ordered list of key prefixes the cache action falls back to when the primary key finds no entry. The action walks the list top to bottom, and the first prefix that matches at least one stored entry restores the most recently created of those entries, so a job whose lockfile just changed still starts from the previous run's dependency tree instead of an empty directory.

Answer

Every cache entry is stored under one exact string. A job asks for that string in key, and when the string has changed since the last run, the lookup misses and the job starts cold. A lockfile hash inside the key guarantees this happens the moment a dependency moves, which is the point of the hash and also the reason restore-keys exists.

The mechanics are three rules, documented in the GitHub dependency caching reference (checked on 2026-08-13) and mirrored by the restore-keys input in the WarpBuild caching documentation:

  • Matching is by prefix from the left. Linux-node22- matches Linux-node22-npm-8c41f0. It does not match Linux-npm-node22-8c41f0, and a prefix such as Linux-npm- matches neither of them.
  • The list is searched in order. The first prefix with at least one match ends the search, and later prefixes are never evaluated.
  • Within one prefix, the most recently created entry wins.

The primary key is still evaluated first, and an exact match short-circuits the whole list. restore-keys is what runs after that miss.

Which entry each prefix matches

Take a repository holding these four Linux entries, and a pull request job whose lockfile hash has just changed to b71e05:

Stored entry keyCreated
Linux-node22-npm-8c41f02026-08-12
Linux-node22-yarn-51ad3c2026-08-09
Linux-node22-npm-2b90d72026-08-05
Linux-node20-npm-77b3ee2026-07-28

The job requests key: Linux-node22-npm-b71e05 with a three-rung ladder. The primary key misses because no entry carries that hash.

RungPrefixEntries it matchesOutcome
1Linux-node22-npm-8c41f0, 2b90d7Restores 8c41f0, the newer of the two. Search stops.
2Linux-node22-8c41f0, 51ad3c, 2b90d7Never evaluated.
3Linux-all fourNever evaluated.

Now bump the Node major to 24. The key becomes Linux-node24-npm-b71e05, rung 1 becomes Linux-node24-npm- and matches nothing, rung 2 becomes Linux-node24- and matches nothing, and rung 3 is still Linux-. Rung 3 matches all four entries and restores Linux-node22-npm-8c41f0, a tree installed under a different Node major. That rung is where a ladder starts working against the job, and the Detail section below covers when to remove it.

Cache behavior is the same across WarpBuild's Linux and macOS runners. The WarpBuild cache backend covers everything except the Windows labels, per the limitations section of the caching documentation. WarpBuilds/cache@v1 is a drop-in for actions/cache@v4 and accepts the same key and restore-keys inputs, so a ladder written for one works unchanged on the other.

Detail

A three-rung ladder in a workflow

name: build

on:
  push:
    branches: [main]
  pull_request:

env:
  NODE_VERSION: "22"

jobs:
  build:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v5

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}

      - name: Restore npm cache
        id: npm-cache
        uses: WarpBuilds/cache/restore@v1
        with:
          path: |
            ~/.npm
            node_modules
          key: Linux-node${{ env.NODE_VERSION }}-npm-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            Linux-node${{ env.NODE_VERSION }}-npm-
            Linux-node${{ env.NODE_VERSION }}-
            Linux-

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

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

Three details in that file are worth reading closely.

npm ci runs unconditionally. After a prefix restore, cache-hit is false by design, and the install is what reconciles the restored tree against the lockfile that moved. Guarding the install on cache-hit would leave the job running against a dependency set the lockfile no longer describes.

The save is guarded on cache-hit != 'true' and writes cache-primary-key, the exact string the restore step computed. Entries are immutable, so a save under a key that already exists writes nothing. Splitting restore and save keeps the entry publishable even when a later step fails, which the persistent caches guide covers alongside the cases where a cache is the wrong mechanism entirely.

The ladder narrows one segment at a time. Each rung drops the rightmost component of the key, which only works because the key is ordered from general on the left to specific on the right. restore-keys and the key layout are one design, and cache key covers the segment ordering that makes a truncated key meaningful.

If you would rather not maintain any of this, the cache-enabled forks listed in the setup actions documentation build the key and the fallback ladder for Node.js, Python, Go, Java, .NET, Ruby, Zig, Rust, Gradle, and mise without a cache step in the workflow.

Why a partial restore usually beats a cold start

A prefix restore hands the job a dependency tree that is one lockfile edit out of date. npm ci over a warm ~/.npm reads the unchanged tarballs from disk and downloads only the delta, so the work left is proportional to the change rather than to the size of the dependency set.

Here is a month for a repository at 600 runs on warp-ubuntu-latest-x64-4x at $0.008 per minute (from the WarpBuild pricing page, checked on 2026-08-13) with a 1.2 GB entry. Cache is billed separately at $0.20 per GB-month for storage and $0.0001 per operation on hosted runners, and is free on BYOC, both from the pricing table in the caching documentation, checked on 2026-08-13. Substitute your own dependency-step timings; the three columns are what the ladder decides between.

LineNo cacheExact key hitPrefix restore
Dependency minutes per run4.00.41.2
Runner minutes per month2,400240720
Runner cost at $0.008 per minute$19.20$1.92$5.76
Cache storage, 1.2 GB at $0.20 per GB-month$0.00$0.24$0.24
Cache operations, 1,200 at $0.0001$0.00$0.12$0.12
Monthly total$19.20$2.28$6.12

The middle column is the ceiling a repository would reach if its lockfile never changed, and no real repository sits there. The right column is where an active repository actually lands, and the gap between it and the left column is the entire argument for a ladder.

The case where a partial restore is worse

Two shapes turn the fallback into a loss.

The first is an entry restored from a rung so broad that its contents do not apply. Native modules compiled against one Node ABI, a Rust target directory produced by a different rustc, and a Python virtualenv built for another minor version all restore cleanly and then either fail at link time or, worse, run against binaries nobody rebuilt. That is the Linux- rung in the ladder above after a Node major bump. Remove the widest rung on any project with compiled dependencies, and keep the toolchain version inside every prefix that survives.

The second is arithmetic. Restoring 1.2 GB costs wall clock time before the install step even starts, and after a prefix match the install runs anyway. When the restore takes longer than the install it partially replaces, the ladder has added a download for nothing. Large entries are where this bites, and entry size interacts with eviction: the cache size limit guide covers the ceiling and the least-recently-used eviction that decides which of your prefixes still have anything to match. WarpBuild cache entries expire 7 days after their last use, per the caching documentation, so a rung pointing at a branch nobody has built in a week matches nothing at all.

The fix for both is the same: fewer rungs, each carrying the toolchain identity, and the widest rung deleted rather than left in place as insurance.

In what order are restore-keys tried?

Top to bottom, in the order written in the workflow. The first prefix that matches at least one entry ends the search, and the remaining prefixes are never evaluated. When one prefix matches several entries, the most recently created entry is the one restored, per the GitHub dependency caching reference, checked on 2026-08-13. Order the list from the most specific prefix to the least, because a broad rung placed above a narrow one makes the narrow one unreachable.

Does a restore-keys match set cache-hit to true?

No. The cache-hit output is true only when the primary key matched exactly. A restore that lands through a prefix sets cache-hit to false, which the WarpBuild caching documentation records for both the combined action and the split restore and save actions. That is why an install step guarded on cache-hit != 'true' still runs after a partial restore, and why guarding it differently breaks the reconciliation the partial restore depends on.

Do restore-keys change what the job saves?

No. The save step always writes under the primary key, so a job that restored through Linux-node22-npm- still publishes a new entry under Linux-node22-npm-b71e05. That is how the ladder stays useful over time: each run adds one entry at the exact key, and later runs fall back to it by prefix. How to write a good cache key covers the key construction that makes those prefixes line up, and the persistent caches guide covers what to do when the state you need is larger than a cache entry should be.

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.