DEV Community

jidonglab
jidonglab

Posted on

GitHub Actions Cache Never Saves on a Key Hit: 23 Days of Stale CI

My CI install step used to take 38 seconds. Three weeks later it took 2 minutes 51. Nobody had touched the workflow file. The cache step was green on every run and said Cache restored successfully like a waiter smiling at you while your food goes cold.

The GitHub Actions cache wasn't broken. It was doing exactly what it's designed to do: when the key matches exactly, it restores the cache and then refuses to save a new one. My cache was 23 days old, and every run was downloading the same growing pile of packages on top of it.

TL;DR

  • A GitHub Actions cache entry is immutable. If key matches an existing entry exactly, actions/cache restores it and skips the save at the end of the job.
  • A static key like linux-venv therefore freezes your cache forever (until it's evicted). Put hashFiles() of your lockfile in the key, and use restore-keys as a prefix fallback.
  • Caches are branch-scoped: a run can read caches from its own branch and the default branch (plus the base branch for PRs), never from sibling branches.
  • The save runs in a post step that only fires when the job succeeds. Failed jobs save nothing unless you split into actions/cache/restore and actions/cache/save.
  • Entries not accessed for 7 days are deleted, and the repo has a total size cap, so old caches get evicted.

Why doesn't my GitHub Actions cache update?

Because an exact key hit means "this cache is already correct," so actions/cache doesn't write a new one. Cache entries can't be overwritten. The only way to get fresh content into the cache is to save it under a key that doesn't exist yet.

Here's what I had:

- uses: actions/cache@v4
  with:
    path: .venv
    key: ${{ runner.os }}-venv

- run: |
    python -m venv .venv
    .venv/bin/pip install -r requirements.txt
Enter fullscreen mode Exit fullscreen mode

Day 1: cache miss, full install, post step saves Linux-venv. Great.

Day 2 onward: someone adds a dependency. The job restores the day-1 venv, pip installs the new package, the tests pass. Then the post step prints this and exits:

Cache hit occurred on the primary key Linux-venv, not saving cache.
Enter fullscreen mode Exit fullscreen mode

The new package is never persisted. The next run restores the day-1 venv again and downloads it again. Twenty-three days of dependency bumps later, every run was reinstalling a third of requirements.txt from PyPI while the cache step proudly reported a hit.

That log line is the whole bug. Search your Actions logs for not saving cache right now. If it shows up on a job whose dependencies have changed since the cache was created, you have a frozen cache.

What does restore-keys actually do?

restore-keys is a list of prefixes used only when the primary key misses. GitHub picks the most recently created cache whose key starts with the first prefix that matches anything. When that happens, the cache-hit output is false, so the post step does save a new entry under your exact primary key.

That's the mechanic you actually want:

- uses: actions/cache@v4
  with:
    path: .venv
    key: ${{ runner.os }}-venv-${{ hashFiles('requirements.txt') }}
    restore-keys: |
      ${{ runner.os }}-venv-
Enter fullscreen mode Exit fullscreen mode

Now the flow is:

  1. requirements.txt unchanged: exact hit, restore, skip save. Correct, nothing to save.
  2. requirements.txt changed: exact miss, prefix match restores the newest old venv, pip installs only the delta, the post step saves a brand-new entry under the new hash.
  3. Next run: exact hit on the new entry.

My install step went back to 36 seconds on the first run after this change and stayed there.

One gotcha: cache-hit is true only for an exact primary-key match. If you wrote if: steps.cache.outputs.cache-hit != 'true' to skip installs, a restore-keys match still runs your install. That's usually what you want, because the restored content is stale by definition.

Why can't my branch see another branch's cache?

GitHub scopes caches by branch ref. A workflow run can restore caches created on its own branch or on the default branch. Runs triggered by a pull request can also restore caches from the PR's base branch. A run can never read a cache created on a sibling branch or a child branch.

This produces two confusing symptoms:

  • Every new feature branch starts cold if main never saves a cache. If your workflow only runs on pull_request, main has no cache to share, and each PR builds its own from scratch.
  • The first push after a merge misses. PR runs save caches scoped to the PR's merge ref, which nothing else can read. After you merge, main has to build and save its own copy.

The fix is boring: make sure your workflow also runs on push to the default branch, so main seeds a cache every branch can fall back to.

on:
  push:
    branches: [main]
  pull_request:
Enter fullscreen mode Exit fullscreen mode

Why didn't my failed job save its cache?

The save in actions/cache happens in a post step that only runs when the job succeeds. If a test fails after a 4-minute dependency install, that install is thrown away, and the next run (probably you, retrying after a fix) pays the full 4 minutes again.

If your install is expensive and your tests are flaky, split the action in two:

- uses: actions/cache/restore@v4
  id: restore
  with:
    path: .venv
    key: ${{ runner.os }}-venv-${{ hashFiles('requirements.txt') }}
    restore-keys: |
      ${{ runner.os }}-venv-

- run: |
    python -m venv .venv
    .venv/bin/pip install -r requirements.txt

- uses: actions/cache/save@v4
  if: steps.restore.outputs.cache-hit != 'true'
  with:
    path: .venv
    key: ${{ runner.os }}-venv-${{ hashFiles('requirements.txt') }}

- run: .venv/bin/pytest
Enter fullscreen mode Exit fullscreen mode

Saving right after the install means a failing test step no longer takes the cache down with it. The if guard avoids the same "already exists" skip you'd get anyway, and keeps the log clean.

Don't save a half-built directory, though. If the install itself can fail midway, keep the save after the install step so it only runs when the install finished.

Does the path list change the cache identity?

Yes. A cache entry is identified by its key and a version derived from the path list (and the compression method). Two steps using the same key but different path values will not restore each other's caches.

I hit this when one job cached .venv and another cached ./.venv/. Same key, same directory, no hits between them. Keep path byte-identical wherever you share a key, or better, keep the cache config in one reusable workflow or composite action.

When does GitHub delete a cache?

Two rules. Any cache entry that hasn't been accessed for 7 days is removed. And a repository has a total cache size limit (10 GB by default); when you go over it, older entries get evicted until you're back under.

Practical consequences:

  • A repo that only runs CI on weekends can lose its caches during a quiet week.
  • A key that embeds github.run_id or a timestamp creates one entry per run. That's a legitimate "always update" pattern with restore-keys, but big caches will churn through the size limit and evict your useful entries.
  • A matrix with 12 OS and version combinations multiplies your storage by 12.

You can inspect and clean all of this from the terminal:

gh cache list --limit 50
gh cache delete Linux-venv
Enter fullscreen mode Exit fullscreen mode

gh cache list shows keys, sizes and last-accessed times. It's the fastest way to spot a frozen static key: its "created" date is weeks old while it's still being hit every day.

What's the right GitHub Actions cache key pattern?

For almost every dependency cache, use this shape:

key:          <os>-<tool>-<hash of lockfile>
restore-keys: <os>-<tool>-
Enter fullscreen mode Exit fullscreen mode
  • Put the lockfile hash in the key so content changes produce a new key.
  • Put the OS (and language version, if the artifact is compiled) in the key so you never restore a Linux wheel on macOS.
  • Keep restore-keys as the prefix up to the hash, so a changed lockfile starts from the newest warm cache instead of from zero.
  • Run the workflow on push to your default branch so every branch has something to fall back to.
  • Split restore and save if failed jobs are common and installs are expensive.

If you use actions/setup-node, setup-python or setup-go with their built-in cache: input, they already build a lockfile-hash key for the package manager's download cache. Most of the frozen caches I've seen come from hand-written actions/cache steps with a key someone typed once and never looked at again.

So why does the GitHub Actions cache never update?

The GitHub Actions cache never updates on an exact key hit because cache entries are immutable: when key matches an existing entry, actions/cache restores it and skips the save, logging Cache hit occurred on the primary key ... not saving cache. A static key therefore freezes the cache at its first-ever content until it's evicted after 7 days without access. Fix it by including hashFiles() of your lockfile in the key and adding a restore-keys prefix, so a dependency change misses the exact key, restores the newest old cache, and saves a fresh entry. Then check the other three traps: caches are only visible to their own branch and the default branch, the save only runs when the job succeeds, and a different path list means a different cache.


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

Top comments (1)

Collapse
 
kashif_manzer profile image
Kashif Manzer •

One more gotcha that produces the same stale-cache symptoms: on pull requests from forks, the save step can silently not run because the default token there has read-only permissions. Your keys can be perfect and the cache still never updates. Running the workflow on push to the default branch as well, which you recommend, covers this. The other pattern that bit us in a monorepo: a single hashFiles over every lockfile means one package changing a lockfile invalidates the cache for all of them. Splitting into one cache step per package directory with its own key fixed it for us.