Skip to content
Latchkey LogoLatchkey home

A GitHub Actions cache-hit output empty value is what a miss looks like

A GitHub Actions cache-hit output empty value is not a bug, it is the documented behavior of a complete miss: the restore step returns without setting the output at all. Conditions written as a comparison against the string false therefore behave backwards on exactly the run where you needed them.

Three restore outcomes, the cache-hit value each produces, and how two conditions read them
A complete miss returns before setOutput is called, so cache-hit is the empty string. Only a comparison against true reads all three states correctly.

What this error means

A step you gated on the cache runs when it should have been skipped, or skips when it should have run, and the workflow is otherwise correct. Nothing in the log says why, because an if: that evaluates to false produces a skipped step and no explanation. The output that decided it is never printed unless you print it yourself, and the two values that behave differently, an empty string and the string false, render identically in most places you might echo them.

.github/workflows/ci.yml, the comparison that never fires on a miss (illustrative)
- id: cache
  uses: actions/cache@v6
  with:
    path: ~/.npm
    key: npm-${{ hashFiles('package-lock.json') }}

- if: steps.cache.outputs.cache-hit == 'false'
  run: npm ci

The early return that leaves the output unset

The restore step has three exits and only two of them set the output. When nothing matched, the code returns before setOutput is reached, and the comment above it says so explicitly, pointing at the issue where the behavior was pinned down. The output is then the empty string, because an output that was never set reads as empty in an expression.

When something did match, the output is set to the result of an exact-key comparison rendered as a string. An exact match gives true. A match that came from a restore-keys prefix gives false, and the action documents that in the description of the restore-keys input itself.

actions/cache, src/restoreImpl.ts (v6.1.0)
if (!cacheKey) {
    // `cache-hit` is intentionally not set to `false` here to preserve existing behavior
    // See https://github.com/actions/cache/issues/1466
    ...
    return;
}

const isExactKeyMatch = utils.isExactKeyMatch(
    core.getInput(Inputs.Key, { required: true }),
    cacheKey
);

core.setOutput(Outputs.CacheHit, isExactKeyMatch.toString());

Common causes

The condition compares against false

The dominant cause, and it looks entirely reasonable. Treating the output as a boolean with two values leads directly to a condition that is silently wrong on a complete miss, which is the run where skipping the install produces a job that fails much later for reasons that have nothing to do with the cache.

The condition treats the output as a real boolean

Workflow expressions coerce, and an unquoted comparison against false does not do what a reader expects when the left side is an empty string. Quoting both sides and comparing against the string keeps the evaluation in one type and makes the three states visible.

A restore-keys hit is treated as a full hit

The opposite mistake, and a more expensive one. Skipping the install whenever anything was restored leaves a partially populated store and a job that fails on a missing module, usually far from the cache step and usually blamed on the dependency rather than the condition.

The step has no id

Worth ruling out first because it is instant. Without an id on the cache step there is nothing to reference, the expression resolves to empty, and every condition written against it reads as a miss. The workflow is valid and nothing warns.

How to fix it

Compare against true and nothing else

One form is correct in all four states. Skip the expensive step when the output is exactly the string true, and let every other value fall through to doing the work. This is also the form the action documentation uses, which helps the next person reading your file.

.github/workflows/ci.yml (illustrative)
      - id: cache
        uses: actions/cache@v6
        with:
          path: ~/.npm
          key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            npm-${{ runner.os }}-

      - if: steps.cache.outputs.cache-hit != 'true'
        run: npm ci

Print the value once, with quotes around it

  1. Add a step after the cache step that echoes the output inside literal quotes.
  2. Run the workflow on a branch with no cache at all, and read the value.
  3. Run it again so the key matches exactly, and read it again.
  4. You should see an empty pair of quotes first and the word true second; that is the whole behavior.
.github/workflows/ci.yml (illustrative)
      - run: echo "cache-hit is '${{ steps.cache.outputs.cache-hit }}'"

Do not skip installs on a prefix match

If you have a condition that skips whenever anything was restored, tighten it. A prefix match means the store is warm rather than complete, and the install that follows is the cheap kind. The time you save by skipping it is smaller than the time you lose the first day it breaks.

Keep the id and the reference next to each other

Give every cache step an id even when nothing reads it yet, and put the gated step immediately below rather than several steps down. Most of the versions of this bug that survive review are ones where the two halves are far enough apart that nobody reads them together.

Three states, two conditions, one correct answer

Laid out against the two ways people write the condition, the problem is obvious and it is only obvious once you know there are three states rather than two. A comparison against the string true is correct in every row. A comparison against the string false is correct in one row out of three and, worse, is wrong in the row that matters most, the complete miss, where skipping the install is the one thing you must not do.

The fourth state is rarer and behaves like the second. When the cache service is unavailable to the job, the action sets the output to false at the top and returns, so a disabled cache reads as a partial hit. A condition that skips work on anything other than true handles that correctly by accident, which is the argument for writing it that way even if you never expect it.

What happenedcache-hit value!= 'true' skips== 'false' skips
exact key matchedtrueno, correctlyno, correctly
restore-keys prefix matchedfalseyes, correctlyyes, correctly
nothing matched at all(empty)yes, correctlyno, wrongly
cache unavailable in this jobfalseyes, correctlyyes, correctly

What a partial hit is actually worth

The reason the action distinguishes a prefix match from an exact one is that they mean different things to the work you were going to skip. An exact match means the archive was built from inputs identical to the ones you have now, so the installed tree is complete and you can skip the install. A prefix match means it was built from inputs that were near, so the store is warm and the install will be fast, but it is not necessarily complete.

That is why the safe pattern is to skip only on true and to let everything else fall through to the install. On a prefix match the install is cheap because most of what it needs is already on disk; on a miss it is expensive and necessary. You are not trading much by running it in the first case, and you are avoiding a broken job in the second.

It is also why this behavior has stayed as it is across majors. The output means the same thing in version 4 of the action as it does in version 6, including the early return on a miss, so a condition that is correct today does not need revisiting when you bump the version.

Why there is no recorded run on this page

There is no failure to record, which is unusual even among the quiet pages in this area. Every step in the affected workflow does exactly what it was told to do; the defect is a mismatch between what the author believed the output contained and what it contains. A log cannot show a belief, and the run of a workflow with this bug is indistinguishable from the run of a correct one until you compare the skipped steps against what you meant.

The value that carries the whole argument is not in the log at all. Outputs are not printed unless a step prints them, and the distinction the page turns on, an empty string against the string false, is invisible in most renderings even when you do print it. The fix below quotes the value for that reason, and it is worth doing in your own workflow once rather than trusting a screenshot of ours.

How to prevent it

  • Write cache conditions as != 'true' and never as == 'false'.
  • Give every cache step an id, whether or not something reads it today.
  • Keep the gated step directly below the cache step it depends on.
  • Treat a restore-keys hit as a warm store, not as a finished install.

Frequently asked questions

Why is cache-hit empty instead of false?
Because on a complete miss the restore step returns before it sets the output, and the code carries a comment saying that is intentional and pointing at the issue that fixed the behavior in place. An output that was never set reads as the empty string in a workflow expression.
Is cache-hit true after a restore-keys match?
No. It is the string false, and the action says so in the description of the restore-keys input. The output reports whether the primary key matched exactly, not whether anything was restored, so a prefix match restores data and still reports false.
How should I skip a step when the cache was hit?
Gate it on steps.<id>.outputs.cache-hit != 'true'. That runs the step on an exact miss, on a prefix match and when the cache service is unavailable, and skips it only when the exact key was found. Every other form is wrong in at least one of those states.
Has the cache-hit behavior changed between versions?
No. The early return on a miss and the exact-match comparison are the same in version 4 and in version 6 of the action, comment included. A condition written correctly for one works unchanged on the other, which is one fewer thing to check when you bump the major.

Related guides

References

Keep the condition, change the restore. Latchkey Fast Cache takes the same key and restore-keys. Start free → 30-day trial · No credit card