GitHub Actions reusable workflow outputs empty in the caller
GitHub Actions reusable workflow outputs empty in the caller is almost never a fault in the caller, and it is almost never a race. A value has to be republished at four separate levels to travel from a step to a downstream job, and an empty string at the end tells you only that one of those four hops was missing.

What this error means
Both workflows are green. The called workflow does its work, the caller runs the job that depends on it, and the step that should print a version, a tag or an image digest prints a blank line instead. Nothing is red, nothing is skipped, and adding echo statements inside the called workflow shows the value existing there perfectly well. The gap is invisible because an output that was never declared and an output that is legitimately empty look identical from the caller.
Run echo "version=$VERSION"
version=
shell: /usr/bin/bash -e {0}
version=The pair of files, written out in full
These two files are written for this page and have never been run. They are the smallest complete pair: a called workflow that produces a value and a caller that reads it. Both halves matter, because the commonest mistake is to write one of them correctly and assume the other follows.
Read the called workflow from the bottom up. The step writes to the output file with an id attached. The job republishes that step output under outputs. The workflow republishes the job output under on.workflow_call.outputs. Only then does the caller have anything to read.
# build.yml, the called workflow
on:
workflow_call:
outputs:
version:
description: The version that was built
value: ${{ jobs.compile.outputs.version }}
jobs:
compile:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
steps:
- id: v
run: echo "version=1.2.3" >> "$GITHUB_OUTPUT"Common causes
The workflow never declared the output
The commonest by a long way. A job inside the called workflow has an outputs block and everyone assumes that is enough, but a caller reads only what on.workflow_call.outputs publishes. Without that block the reusable workflow has no outputs at all, and asking for one returns an empty string rather than an error.
The job did not republish the step output
The middle hop is the easiest to skip because the step already works. A job with no outputs mapping exposes nothing, so the workflow-level declaration resolves against a job output that does not exist and yields nothing to pass on.
The consuming job has no needs
Reading needs.<job>.outputs requires that job to be in needs. Without it the needs context has no entry to resolve, so the expression evaluates to an empty string and the job also starts before the call has finished, which makes it look like a timing problem.
The producing job was skipped
A job in the called workflow guarded by an if, or one that depends on a job that failed, produces no outputs. The caller sees empty values with no failure anywhere, because skipping is a success. This is the one that appears only on some branches or some events, which is what makes it hard to pin down.
How to fix it
Declare the output at all four levels, in order
- Give the producing step an
idand writename=valueto the file in$GITHUB_OUTPUT. - Add an
outputsblock on the job that mapssteps.<id>.outputs.<name>. - Add
on.workflow_call.outputs.<name>.valuepointing atjobs.<job>.outputs.<name>. - In the caller, add
needson the consuming job and readneeds.<call-job>.outputs.<name>.
Check the four names against each other before changing logic
Print the whole needs context in the consuming job and compare it with the declarations. This shows in one step whether the caller sees an output with that name at all, which separates a missing declaration from a wrong value, and it costs one run instead of four.
- run: echo '${{ toJSON(needs) }}'Handle the skipped case explicitly
When the producing job is conditional, decide what the caller should do with an absent value rather than letting an empty string flow downstream. Reading needs.<job>.result alongside the output makes the difference between skipped and empty visible, and a default with the || operator keeps the downstream step from receiving a blank.
- run: ./deploy.sh "${{ needs.build.outputs.version || 'none' }}"
if: needs.build.result == 'success'Do not try to forward outputs from the calling job
A job that calls a reusable workflow cannot declare outputs, because the schema shape for that kind of job does not have the key. Downstream jobs should depend on the calling job directly and read the reusable workflow's own outputs through it, rather than through an intermediate job that republishes them.
publish:
needs: build # the job that has `uses:`
runs-on: ubuntu-latest
steps:
- run: echo "${{ needs.build.outputs.version }}"The caller side, and the key it does not have
The caller adds needs on the consuming job and reads the workflow-level output name, not the job-level one. The names are allowed to differ at every hop, which is convenient and is also how a rename in one file quietly empties a value in another.
One thing the calling job cannot do is declare outputs of its own. The workflow schema offers two shapes for a job, and the one that accepts uses lists only name, uses, with, secrets, needs, if, permissions, concurrency and strategy. The documentation publishes the same list under "Supported keywords for jobs that call a reusable workflow". So an outputs block on the calling job is not a subtler way of forwarding a value; it is a key the schema rejects.
# ci.yml, the caller
jobs:
build:
uses: ./.github/workflows/build.yml
publish:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "building ${{ needs.build.outputs.version }}"Where the value is lost
Each row below is one hop, the key that carries the value across it, and what an empty value at the end means if that hop is the missing one. The documentation is specific about the third row: "The value must be set to the value of a job-level output within the called workflow. Step-level outputs must first be mapped to job-level outputs."
The fourth row is the one with a second failure mode. A calling job that was skipped, by its own if or because something it needed failed, produces no outputs at all, so the caller reads an empty string rather than an error. That follows from what a skipped job is: it runs no steps, so nothing is ever written to $GITHUB_OUTPUT, and the outputs block has nothing to republish. The key the caller reads still exists, so the expression resolves to an empty string instead of failing.
| Hop | Key that carries it | What an empty value means here |
|---|---|---|
| step to step context | echo "k=v" >> "$GITHUB_OUTPUT" plus an id | the step has no id, or nothing was written |
| step to job | jobs.<id>.outputs.<name> | the job has no outputs block |
| job to called workflow | on.workflow_call.outputs.<name>.value | the workflow never declared the output |
| called workflow to caller | needs.<call-job>.outputs.<name> | no needs, wrong name, or the call job was skipped |
What the value expression may refer to
The value under on.workflow_call.outputs is not an ordinary expression slot. The schema types it as a workflow output context, and the documented context availability for on.workflow_call.outputs.<output_id>.value is github, jobs, vars and inputs. There is no steps and no env in that list, which is why pointing it straight at a step output does not merely return nothing: it is rejected when the file is parsed, with an unrecognized named-value naming the context you reached for.
That difference is useful for diagnosis. A wrong context here fails loudly at validation, so if your run started at all, the mistake is a missing declaration rather than a bad reference.
on:
workflow_call:
outputs:
version:
value: ${{ jobs.compile.outputs.version }} # jobs context: allowed
# value: ${{ steps.v.outputs.version }} # steps context: not allowed hereWhy there is no recorded run on this page
The signature of this failure is an empty string, and an empty string is the one thing a log cannot attribute. A recorded run would show a step printing nothing, which is exactly what a run with a correctly empty value also shows, and exactly what a run with a typo in the output name shows. The log would not separate the four hops, so it would add a picture without adding evidence. What does separate them is the declaration in each file, which is why this page is built out of the two files in full and the context list the parser enforces. The excerpt at the top is labeled as a shape rather than a capture for the same reason: there is no error text here to quote.
How to prevent it
- Keep the same name at all four hops, so a rename cannot land in one file only.
- Give every reusable workflow output a
description, which forces the declaration to exist. - Print
toJSON(needs)in a debug step while wiring a new reusable workflow. - Treat an empty output as a missing declaration until you have proved otherwise.
Frequently asked questions
Why are my reusable workflow outputs empty in the caller?
on.workflow_call.outputs was never declared in the called workflow. A job-level outputs block is not visible to a caller on its own; the workflow has to republish it. An undeclared output resolves to an empty string rather than raising an error, which is why nothing turns red.Can a job that calls a reusable workflow declare its own outputs?
uses accepts only name, uses, with, secrets, needs, if, permissions, concurrency and strategy, and the documentation publishes the same list. Downstream jobs read the reusable workflow's outputs through the calling job with needs.Can workflow_call outputs point straight at a step output?
github, jobs, vars and inputs. Referencing steps there is rejected when the file is parsed, so the run never starts.What happens to outputs when the producing job is skipped?
needs.<job>.result alongside the output when the producing job is conditional, so an absent value is distinguishable from a blank one.