Skip to content

GitHub Actions Outputs: Passing Values Between Steps and Jobs

Lesson 10 of 11Beginner → Intermediate10 min readGitHub Actions & CI/CD · Actions FundamentalsVerified: GitHub Actions workflow commands documentation, August 2026

A workflow that computes a value in one place and uses it in another needs outputs. Which mechanism depends on how far the value has to travel.

DistanceMechanism
Step → later step, same job$GITHUB_OUTPUT or $GITHUB_ENV
Job → dependent jobJob outputs
Called workflow → callerworkflow_call outputs
Job → job, as filesArtifacts

A step writes to the file at $GITHUB_OUTPUT; later steps read it through the steps context.

- id: version
run: echo "value=1.4.0" >> "$GITHUB_OUTPUT"
- run: echo "Building ${{ steps.version.outputs.value }}"

What it doesComputes a version string and publishes it as a named output, then reads it in a later step.

Why we run itThe step needs an `id` so later steps can reference it. Writing `name=value` to $GITHUB_OUTPUT is what makes the value available — a shell variable would not survive the step.

Expected resultThe second step prints the value the first computed.

Three requirements, all of which produce silence rather than errors when missed:

The step needs an id. Without it there is nothing to reference.

The format is name=value, appended to the file. Not export, not echo to stdout.

The reference is steps.<id>.outputs.<name>. A wrong id yields an empty string, not a failure — which is why a typo here produces a downstream error that mentions something else entirely.

Computed values work as you would expect:

- id: meta
run: |
{
echo "sha_short=$(git rev-parse --short HEAD)"
echo "date=$(date -u +%Y-%m-%d)"
echo "branch=${GITHUB_REF_NAME}"
} >> "$GITHUB_OUTPUT"
- run: echo "Tag: ${{ steps.meta.outputs.date }}-${{ steps.meta.outputs.sha_short }}"

A value containing newlines needs a heredoc-style delimiter:

- id: notes
run: |
{
echo "changelog<<MGA_EOF"
git log --oneline -10
echo "MGA_EOF"
} >> "$GITHUB_OUTPUT"
- run: |
cat <<'NOTES'
${{ steps.notes.outputs.changelog }}
NOTES

The delimiter must not appear in the value. A random or distinctive one avoids a value that happens to contain EOF truncating the output silently.

Jobs are isolated, so a value crossing a job boundary must be declared at job level:

jobs:
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.meta.outputs.version }}
digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@v7
- id: meta
run: echo "version=1.4.0" >> "$GITHUB_OUTPUT"
- id: push
run: echo "digest=sha256:a3f8c21" >> "$GITHUB_OUTPUT"
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: |
echo "Deploying ${{ needs.build.outputs.version }}"
echo "Image digest ${{ needs.build.outputs.digest }}"

Two layers: the step publishes to $GITHUB_OUTPUT, the job re-exports it under outputs:, and the dependant reads it through needs.<job>.outputs.<name>.

Both layers are required. A step output not listed in the job’s outputs: is invisible outside the job.

A job can only read outputs from jobs it declares in needs. That is not a lookup restriction — without needs, the jobs may run simultaneously and the value would not exist yet.

A matrix job runs many times, and they all write to the same job output. The last to finish wins, non-deterministically.

jobs:
build:
strategy:
matrix:
target: [linux, darwin, windows]
outputs:
artifact: ${{ steps.build.outputs.name }} # unreliable — which one?

For per-variant values, use artifacts named after the matrix value, and have the consuming job download them. Job outputs are the wrong shape for a matrix, and the failure is intermittent rather than immediate — the worst kind.

A reusable workflow declares outputs at the workflow_call level, sourced from its jobs:

# .github/workflows/build.yml — the called workflow
on:
workflow_call:
outputs:
image-digest:
description: The digest of the pushed image
value: ${{ jobs.build.outputs.digest }}
jobs:
build:
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- id: push
run: echo "digest=sha256:a3f8c21" >> "$GITHUB_OUTPUT"
# The caller
jobs:
build:
uses: ./.github/workflows/build.yml
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "Deploying ${{ needs.build.outputs.image-digest }}"

Three layers now — step, job, workflow — and every one must declare the value or it stops there.

Outputs are strings. For anything structured, JSON plus fromJSON is the usual approach:

- id: config
run: echo 'matrix={"include":[{"os":"ubuntu-latest"},{"os":"macos-latest"}]}' >> "$GITHUB_OUTPUT"
- run: echo "First OS is ${{ fromJSON(steps.config.outputs.matrix).include[0].os }}"

This is also how dynamic matrices work — one job computes a matrix as JSON, a dependent job consumes it:

jobs:
discover:
runs-on: ubuntu-latest
outputs:
targets: ${{ steps.find.outputs.targets }}
steps:
- uses: actions/checkout@v7
- id: find
run: |
targets=$(ls services/ | jq -R -s -c 'split("\n")[:-1]')
echo "targets=$targets" >> "$GITHUB_OUTPUT"
build:
needs: discover
strategy:
matrix:
service: ${{ fromJSON(needs.discover.outputs.targets) }}
runs-on: ubuntu-latest
steps:
- run: echo "Building ${{ matrix.service }}"

That pattern — discover, then fan out — is how a monorepo builds only the services that exist, without listing them in the workflow.

Outputs have a size limit, and a value exceeding it is truncated. Anything large — a build log, a full changelog, a report — belongs in an artifact, not an output.

Outputs are not masked. A secret written to $GITHUB_OUTPUT appears in the run’s data and may be visible in logs. Secrets move through secrets, never through outputs.

Outputs from untrusted input need validation. A value derived from a pull request title or branch name and passed to a later step is attacker-influenced, and later steps may interpolate it into a command. Validate before publishing, and consume through env: rather than interpolation.

The most common real need for outputs is collecting results from a matrix — and job outputs cannot do it, because every matrix variant writes to the same place.

The working pattern uses artifacts as the transport and one job to gather them:

jobs:
build:
strategy:
matrix:
target: [linux, darwin, windows]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- id: build
run: |
digest=$(./build.sh "${{ matrix.target }}")
echo "digest=$digest" >> "$GITHUB_OUTPUT"
- run: |
mkdir -p results
echo '{"target":"${{ matrix.target }}","digest":"${{ steps.build.outputs.digest }}"}' \
> "results/${{ matrix.target }}.json"
- uses: actions/upload-artifact@v7
with:
name: result-${{ matrix.target }}
path: results/
collect:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v8
with:
pattern: result-*
merge-multiple: true
path: results/
- run: jq -s '.' results/*.json

pattern plus merge-multiple downloads every matching artifact into one directory, which is precisely designed for this shape. Each variant contributes a file named after itself; the collecting job reads them all.

That is more machinery than a job output, and it is the only approach that works reliably. A matrix writing to a job output produces whichever variant finished last — non-deterministically, so it passes in testing and surprises you later.

Values flow into a reusable workflow as inputs, and those can themselves come from an earlier job’s outputs:

jobs:
prepare:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.meta.outputs.version }}
steps:
- id: meta
run: echo "version=1.4.0" >> "$GITHUB_OUTPUT"
deploy:
needs: prepare
uses: ./.github/workflows/deploy.yml
with:
version: ${{ needs.prepare.outputs.version }}

Note that a job calling a reusable workflow uses uses: at job level and cannot have steps: of its own. Its with: block supplies the called workflow’s inputs.

Outputs are subject to a size limit, and exceeding it truncates rather than failing — which is the worst possible behaviour, because a truncated JSON string parses as invalid rather than as incomplete.

Guard anything that could grow:

- id: report
run: |
summary=$(./generate-summary.sh)
if [ "${#summary}" -gt 4000 ]; then
echo "::warning::summary truncated for output; full version in the artifact"
summary="${summary:0:4000}"
fi
{
echo "summary<<MGA_EOF"
echo "$summary"
echo "MGA_EOF"
} >> "$GITHUB_OUTPUT"

For anything genuinely large, publish an artifact and pass its name as the output. The value stays small and the data stays retrievable.

No id on the producing step. Nothing to reference; the output is empty.

Using set-output. Deprecated and disabled; fails silently.

Forgetting the job-level outputs: block. Step outputs stop at the job boundary.

Reading an output without needs. No dependency, no value.

Job outputs from a matrix. Non-deterministic; use artifacts.

Large values as outputs. Truncated; use an artifact.

A secret in an output. Not masked.

A typo in the output name. Yields an empty string, not an error.

Outputs are most useful when several jobs coordinate. This is the shape most deployment pipelines take:

name: Build and deploy
on:
push:
branches: [main]
permissions:
contents: read
jobs:
version:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.meta.outputs.tag }}
is_release: ${{ steps.meta.outputs.is_release }}
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- id: meta
run: |
tag="$(git describe --tags --always --dirty)"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
if git describe --exact-match --tags >/dev/null 2>&1; then
echo "is_release=true" >> "$GITHUB_OUTPUT"
else
echo "is_release=false" >> "$GITHUB_OUTPUT"
fi
build:
needs: version
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@v7
- id: push
run: |
digest="$(./build-and-push.sh '${{ needs.version.outputs.tag }}')"
echo "digest=$digest" >> "$GITHUB_OUTPUT"
deploy:
needs: [version, build]
if: needs.version.outputs.is_release == 'true'
runs-on: ubuntu-latest
environment: production
steps:
- run: ./deploy.sh
env:
IMAGE_DIGEST: ${{ needs.build.outputs.digest }}
VERSION: ${{ needs.version.outputs.tag }}

Three things to notice.

fetch-depth: 0 on the checkout, because git describe needs history and the default checkout is shallow. A version job producing the wrong tag is almost always this.

The boolean is a string. is_release is compared against 'true', because outputs are strings regardless of what produced them.

The deploy job consumes a digest, not a tag. It deploys exactly what was built, which is the build once, promote principle in practice. Rebuilding from the tag would produce a different image from the one that was tested.

Two considerations that matter once outputs carry data from outside the workflow.

Outputs are not masked. A secret written to $GITHUB_OUTPUT may appear in logs and in the run’s stored data. Secrets travel through the secrets context; there is no reason for one to become an output.

An output built from untrusted input carries that taint forward. A version string derived from a branch name is attacker-influenced, and a later job interpolating it into a shell command inherits the injection. Validate at the point of production:

- id: meta
run: |
tag="${GITHUB_REF_NAME}"
if ! [[ "$tag" =~ ^[A-Za-z0-9._/-]+$ ]]; then
echo "::error::unexpected characters in ref name"
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"

Validating once where the value enters is better than remembering to quote it at every use.

  1. Write a step that publishes an output and a later step that reads it.
  2. Remove the id and observe that the reference silently becomes empty.
  3. Publish a multiline value using a delimiter, and print it.
  4. Add a second job, declare a job output, and read it from a dependant with needs.
  5. Remove needs and observe the failure.
  6. Build a dynamic matrix: one job emits a JSON array, a second consumes it with fromJSON.

Step 6 is the one worth keeping — it is the foundation of most non-trivial monorepo pipelines.

Both pass values between steps, and the difference is scope and intent.

$GITHUB_ENV$GITHUB_OUTPUT
Visible toLater steps in the same jobAnything referencing steps.<id>
Crosses job boundariesNoYes, via job outputs
Referenced as$VAR in the shell${{ steps.id.outputs.name }}
Needs a step idNoYes
Affects child processesYes — it is the environmentNo

The practical rule: use $GITHUB_ENV when a later command needs the value as an environment variable, and $GITHUB_OUTPUT when the workflow itself needs it — for a condition, for a job output, or as an input to an action.

A value needed by both can be written to both, which is common and perfectly reasonable:

- id: meta
run: |
version="1.4.0"
echo "VERSION=$version" >> "$GITHUB_ENV"
echo "version=$version" >> "$GITHUB_OUTPUT"
  • Step outputs are written to $GITHUB_OUTPUT as name=value and read via steps.<id>.outputs.
  • The producing step needs an id; a missing or wrong one yields silence, not an error.
  • Job outputs need declaring at job level and reading through needs.<job>.outputs.
  • Reusable workflow outputs add a third declaration layer.
  • Matrix jobs cannot reliably produce job outputs — use artifacts.
  • fromJSON turns a string output into structured data, which is how dynamic matrices work.
  • Outputs are size-limited and unmasked; large values and secrets belong elsewhere.

Both move data between jobs. Which one depends on what the data is.

OutputArtifact
CarriesA stringFiles
SizeSmall; truncated beyond a limitLarge, within storage limits
Persists after the runNoYes, until retention expires
Downloadable by a humanNoYes
Requires an actionNoUpload and download actions
CostNoneStorage

The decision is usually obvious once framed as “is this a value or a file?”. A version string, a digest, a boolean flag — output. A binary, a coverage report, a test result file — artifact.

The awkward middle is structured data: a JSON document describing what was built. If it is small and another job needs to branch on it, an output plus fromJSON is simpler. If it is large or a human may want to read it, an artifact is better — and you can do both, publishing a digest as an output and the full report as an artifact.

Outputs fail silently, so a systematic check beats guessing.

  1. Does the producing step have an id? Without one there is nothing to reference and the expression yields an empty string.
  2. Is the syntax name=value appended to $GITHUB_OUTPUT? Not export, not echo to stdout, not the retired ::set-output.
  3. Does the name match exactly? Output names are case-sensitive in practice; a mismatch is invisible.
  4. For a job output, is it declared in the job’s outputs: block? Step outputs stop at the job boundary.
  5. Does the consuming job list the producer in needs? Without it, needs.<job> does not exist.
  6. Is the producing step actually running? A skipped step produces no outputs, and a conditional step that was skipped looks identical to one that produced nothing.

Printing the whole context is the fastest way to see what is actually there:

- run: echo "$STEPS"
env:
STEPS: ${{ toJSON(steps) }}
- run: echo "$NEEDS"
env:
NEEDS: ${{ toJSON(needs) }}

That shows every step’s outputs and outcome, and every dependency’s outputs, in one place — which usually makes the mistake obvious immediately.

Actions publish outputs the same way, and consuming them is identical:

- id: build
uses: docker/build-push-action@v7
with:
push: true
tags: ghcr.io/${{ github.repository }}:latest
- run: echo "Pushed digest ${{ steps.build.outputs.digest }}"

Which outputs an action provides is declared in its action.yml, and reading that file is more reliable than the README:

Terminal window
gh api repos/docker/build-push-action/contents/action.yml \
-H "Accept: application/vnd.github.raw" | grep -A20 '^outputs:'

The digest output above is the foundation of the build once, promote principle — it identifies exactly the image that was built, so later jobs deploy that image rather than rebuilding and hoping.

Five ways to move something, and choosing correctly is most of the difficulty:

MoveFrom → toMechanism
A valueStep → later step$GITHUB_ENV
A valueStep → workflow logic$GITHUB_OUTPUT
A valueJob → dependent jobJob outputs
A valueCalled workflow → callerworkflow_call outputs
FilesJob → job, or out of the runArtifacts

The rows are ordered by scope. Reach for the narrowest one that works: a value only the next step needs does not need to become a job output, and a job output that nothing outside the job reads is unnecessary declaration.

Outputs are how a workflow passes values along. $GITHUB_OUTPUT publishes from a step, a job’s outputs block republishes across the job boundary, and needs.<job>.outputs reads it.

The recurring failure is silence: a missing id, an undeclared job output or an absent needs all produce an empty string rather than an error. When a value is unexpectedly empty, printing toJSON(steps) and toJSON(needs) locates the break faster than reading the workflow again.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

The CI starter template — least-privilege permissions, pinned actions, correct checkout — is in the Professional Toolkit.