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.

A value is republished four times on its way out. Miss any one declaration and the caller reads an empty string with no error.
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.
```The shape of the log, not a capture: there is no error text to quote
Run echo "version=$VERSION"
version=
shell: /usr/bin/bash -e {0}
version=
## 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
1. Give the producing step an `id` and write `name=value` to the file in `$GITHUB_OUTPUT`.
2. Add an `outputs` block on the job that maps `steps.<id>.outputs.<name>`.
3. Add `on.workflow_call.outputs.<name>.value` pointing at `jobs.<job>.outputs.<name>`.
4. In the caller, add `needs` on the consuming job and read `needs.<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.
```.github/workflows/ci.yml (illustrative)
- 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.
```.github/workflows/ci.yml (illustrative)
- 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.
``.github/workflows/ci.yml (illustrative)uses:`
publish:
needs: build # the job that has
runs-on: ubuntu-latest
steps:
- run: echo "${{ needs.build.outputs.version }}"
## 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.
## 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.
```.github/workflows/build.yml (illustrative)
# 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"
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.
```.github/workflows/ci.yml (illustrative)
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 |
> Names may differ at every hop, so a value can disappear because a rename landed in one file and not the other. When a build is green and a value is blank, compare the four names before changing any logic.
## 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.
```.github/workflows/build.yml (illustrative)
on:
workflow_call:
outputs:
version:
value: ${{ jobs.compile.outputs.version }} # jobs context: allowed
# value: ${{ steps.v.outputs.version }} # steps context: not allowed here
Why 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.
FAQ
Why are my reusable workflow outputs empty in the caller?
Almost always because 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?
No. The schema shape for a job with 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?
No. The documentation states the value must be set to a job-level output, and the context availability for that key allows only 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?
They arrive empty. A skipped job produces no outputs, and because skipping counts as success the caller sees no failure either. Read needs.<job>.result alongside the output when the producing job is conditional, so an absent value is distinguishable from a blank one.
Top comments (0)