Skip to content
Latchkey LogoLatchkey home

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.

The four hops a value makes from a step to a downstream caller job
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=

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"

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)
  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.

.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.

HopKey that carries itWhat an empty value means here
step to step contextecho "k=v" >> "$GITHUB_OUTPUT" plus an idthe step has no id, or nothing was written
step to jobjobs.<id>.outputs.<name>the job has no outputs block
job to called workflowon.workflow_call.outputs.<name>.valuethe workflow never declared the output
called workflow to callerneeds.<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.

.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.

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?
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.

Related guides

References

Four hops, four runs of two workflows. Latchkey prices each at $0.0025/min at 2 vCPU. Start free → 30-day trial · No credit card