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.

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.
- id: cache
uses: actions/cache@v6
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
- if: steps.cache.outputs.cache-hit == 'false'
run: npm ciThe 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.
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.
- 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 ciPrint the value once, with quotes around it
- Add a step after the cache step that echoes the output inside literal quotes.
- Run the workflow on a branch with no cache at all, and read the value.
- Run it again so the key matches exactly, and read it again.
- You should see an empty pair of quotes first and the word true second; that is the whole behavior.
- 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 happened | cache-hit value | != 'true' skips | == 'false' skips |
|---|---|---|---|
| exact key matched | true | no, correctly | no, correctly |
| restore-keys prefix matched | false | yes, correctly | yes, correctly |
| nothing matched at all | (empty) | yes, correctly | no, wrongly |
| cache unavailable in this job | false | yes, correctly | yes, 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?
Is cache-hit true after a restore-keys match?
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?
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?
Related guides
References
- actions/cache src/restoreImpl.ts: the early return and the exact-match comparison
- actions/cache action.yml: the restore-keys input description and the cache-hit output
- actions/cache#1466: the issue the comment in the source points at
- Dependency caching reference: using the cache-hit output to skip work
- GitHub Actions documentation