GitHub Actions matrix from a JSON file that produces no jobs
A GitHub Actions matrix from a JSON file passes through four links before it becomes jobs, and the belief that an empty one produces a quiet run with no legs is wrong at the last of them. An empty string handed to the matrix does not expand to zero jobs, it raises an error, so the run you are looking for is failed rather than missing.

What this error means
A generator job reads a JSON file, prints something that looks correct, and goes green. The job that should fan out from it does not fan out. What you see next is the part worth reading carefully: the consumer job is marked failed rather than skipped, and the annotation names the consumer job even though nothing in the consumer job ran. If instead the consumer is genuinely skipped, that is a different problem with a different cause, because a skip means a condition was evaluated and an error means a value was.
gen:
outputs:
targets: ${{ steps.set.outputs.targets }}
steps:
- id: set
run: echo "targets=$(jq -c . targets.json)" >> "$GITHUB_OUTPUT"
test:
needs: gen
strategy:
matrix:
target: ${{ fromJSON(needs.gen.outputs.targets) }}Four links, and where a value goes missing
The file has to reach the matrix through four separate mechanisms and each one can drop it without the next one noticing. The file is read by a command in a step. The command writes a line to the step output file. The job promotes that step output to a job output. The consumer job reads the job output through needs and hands it to fromJSON.
The first link fails when the file is not there. A generator that reads a path relative to the wrong directory, or that runs before the repository is checked out, produces nothing and frequently still exits zero, because most tools treat a missing file as an empty result rather than an error.
The second fails on shape. The output file is read line by line, so a value containing a newline does not survive as a single assignment. JSON printed by a formatter is multi line by default, which is why compact output is not a style preference here but a requirement.
The third fails on wiring. A step output only becomes a job output if the job declares it, with the step id matching. A typo in the id produces an empty job output and no complaint, because an undeclared step output is a legitimate thing to have.
The fourth is the loud one. fromJSON given an empty string raises, and the failure is attributed to the consumer job.
| Link | What an empty value does here | Where you see it |
|---|---|---|
| File to command | command prints nothing, exits zero | the generator log, if you look |
| Command to step output | assignment written but empty | nowhere by default |
| Step output to job output | job output resolves to empty | nowhere by default |
| Job output to matrix | fromJSON raises on the empty string | an annotation naming the consumer job |
Common causes
The file was not there when the generator ran
Reading a path before checkout, or from the wrong working directory. Most tools return nothing for a missing file and exit zero, so the step stays green and the empty value travels on.
The JSON was printed across several lines
The step output file is line oriented. Pretty printed JSON loses everything after the first newline, which usually leaves a fragment that is not valid JSON rather than an obviously empty value.
The step output was never promoted to a job output
The consumer reads through needs, which can only see outputs the producing job declares. A missing declaration or a mismatched step id gives an empty string with no warning anywhere.
The consumer does not declare the producer under needs
Without needs, the needs context has no entry for that job, so the reference resolves to nothing. This is easy to introduce when a job is copied from another file.
The generator legitimately produced nothing
In our experience this is more common than people expect on repositories where the matrix is derived from changed paths. Nothing changed, so nothing should be built, and the pipeline needs to say so rather than fail.
How to fix it
Fail the generator when the file is unusable
Validate before emitting. A validation command that exits non zero moves the failure to the job that knows the reason, instead of letting an empty string reach the matrix and blame the consumer.
- id: set
run: |
test -f targets.json
jq -e . targets.json > /dev/null
echo "targets=$(jq -c '.' targets.json)" >> "$GITHUB_OUTPUT"Emit compact JSON on one line
Use the compact flag so the whole value is a single line. This is not cosmetic: a multi line value cannot survive the step output file intact.
echo "targets=$(jq -c . targets.json)" >> "$GITHUB_OUTPUT"Declare the output on the producing job
- Give the generating step an id.
- Declare an
outputsblock on the generating job that maps a name to that step output. - Reference the job output from the consumer with the same name.
- Check the step id in the outputs block character by character; a typo here is silent.
jobs:
gen:
runs-on: ubuntu-latest
outputs:
targets: ${{ steps.set.outputs.targets }}
steps:
- id: set
run: echo "targets=$(jq -c . targets.json)" >> "$GITHUB_OUTPUT"Print the value on the consumer side
One step in the consumer job that echoes what arrived settles which side of the boundary lost the value. Put it behind a condition you can turn off rather than deleting it once it works.
Default to valid JSON, then decide what empty means
An empty array is a legal matrix value and expands to no jobs without raising. Choose whether that should be a green run with no work or a failure, and if it should fail, fail it in the generator where the reason is available.
echo "targets=${TARGETS:-[]}" >> "$GITHUB_OUTPUT"Why an empty matrix is an error and not a skip
This is the belief worth correcting, because it changes where you look. An empty value does not mean zero combinations and a quietly absent job. The expression is evaluated while the strategy is being turned into a list of job configurations, before any job exists, and an empty string is not valid JSON, so the evaluation raises.
The runner currently runs two expression implementations side by side and compares their exceptions, and the comparison code documents the difference in wording for exactly this case. The legacy implementation throws the reader exception directly, about reading a token from the reader. The newer one wraps it and leads with the words about parsing fromJson. Either may then be wrapped again in the template validation error. The reason the runner bothers to compare them is that the two are expected to disagree on text while agreeing on outcome.
So the consequence is a failed run rather than a missing one, which also means a required check stays red and anything downstream of the consumer does not run. Our page on a fromJSON matrix that will not expand goes through the distinct messages in detail. This page stays on the pipeline that produced the empty value in the first place.
Making each link prove itself
The fix is not one change, it is making each of the four links fail loudly instead of silently. Start at the file. Validate it with a command that exits non zero on bad input, so a missing or malformed file fails the generator step rather than producing an empty string that travels onward.
Then make the value compact on purpose. A single line of JSON survives the output file, and a compact flag is the difference between a value that arrives and one that is truncated at the first newline.
Then echo the job output in the consumer, guarded so the echo cannot itself break. Seeing the value on the consumer side is the single most useful diagnostic here, because it proves which side of the needs boundary lost it.
Finally, give the matrix a default that is valid JSON rather than an empty string. An empty array is a legal value and produces a genuinely empty expansion instead of a parse error, which turns a confusing red run into a clear absence of work. Decide deliberately whether an empty list should be a success or a failure, and if it should be a failure, fail it in the generator where the reason is known.
- id: set
run: |
jq -e . targets.json > /dev/null
echo "targets=$(jq -c '.' targets.json)" >> "$GITHUB_OUTPUT"
- run: 'echo "received: ${{ needs.gen.outputs.targets }}"'Why there is no recorded run on this page
Everything on this page is decided by the contents of one repository file and the shape of one shell command, and a recorded run would show our file and our command. It would demonstrate that we can produce the failure, which nobody doubts, and it would tell you nothing about which of your four links dropped the value.
The claim that matters here, that an empty value raises rather than skipping, is taken from the runner own comparison of its two expression implementations, where the empty string case is called out by name along with the wording each one produces. That is a stronger source than a screenshot, because it is the runner describing its own behavior.
There is nothing to repair automatically either. A generator that produced no targets may be correct: sometimes there really is nothing to build. A runner cannot know which, and inventing a matrix would be worse than failing.
How to prevent it
- Validate generated JSON in the step that generates it, not in the step that consumes it.
- Always emit compact single line JSON into the step output file.
- Keep a permanent echo of the received value in the consumer job.
- Decide once, in writing, whether an empty matrix is a success or a failure.
Frequently asked questions
Why does my JSON driven matrix fail instead of producing no jobs?
Why is my JSON truncated between the generator and the matrix?
Why is the job output empty when the step clearly printed the value?
outputs block that names the step id. A missing declaration or a mistyped id gives an empty string, and nothing warns you, because not declaring an output is perfectly legal.Can a matrix read a JSON file directly?
Related guides
References
- actions/runner: FromJson.cs, the two parsing modes and what each throws
- actions/runner: PipelineTemplateEvaluatorWrapper.cs, the empty-string case compared across implementations
- GitHub Actions: pass information between jobs with job outputs
- GitHub Actions: workflow syntax, jobs.<job_id>.strategy.matrix
- GitHub Actions documentation