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.
| Distance | Mechanism |
|---|---|
| Step → later step, same job | $GITHUB_OUTPUT or $GITHUB_ENV |
| Job → dependent job | Job outputs |
| Called workflow → caller | workflow_call outputs |
| Job → job, as files | Artifacts |
Step outputs
Section titled “Step outputs”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 }}"Multiline outputs
Section titled “Multiline outputs”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 }} NOTESThe delimiter must not appear in the value. A random or distinctive one avoids a value that happens to
contain EOF truncating the output silently.
Job outputs
Section titled “Job outputs”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.
Outputs from a matrix job
Section titled “Outputs from a matrix job”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.
Reusable workflow outputs
Section titled “Reusable workflow outputs”A reusable workflow declares outputs at the
workflow_call level, sourced from its jobs:
# .github/workflows/build.yml — the called workflowon: 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 callerjobs: 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.
Structured values
Section titled “Structured values”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.
Limits and security
Section titled “Limits and security”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.
Outputs and matrix fan-in
Section titled “Outputs and matrix fan-in”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/*.jsonpattern 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.
Passing values to a called workflow
Section titled “Passing values to a called workflow”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.
Size and truncation
Section titled “Size and truncation”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.
Common mistakes
Section titled “Common mistakes”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.
A complete build-and-deploy example
Section titled “A complete build-and-deploy example”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.
Outputs and security
Section titled “Outputs and security”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.
Exercise
Section titled “Exercise”- Write a step that publishes an output and a later step that reads it.
- Remove the
idand observe that the reference silently becomes empty. - Publish a multiline value using a delimiter, and print it.
- Add a second job, declare a job output, and read it from a dependant with
needs. - Remove
needsand observe the failure. - 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.
Outputs versus environment variables
Section titled “Outputs versus environment variables”Both pass values between steps, and the difference is scope and intent.
$GITHUB_ENV | $GITHUB_OUTPUT | |
|---|---|---|
| Visible to | Later steps in the same job | Anything referencing steps.<id> |
| Crosses job boundaries | No | Yes, via job outputs |
| Referenced as | $VAR in the shell | ${{ steps.id.outputs.name }} |
Needs a step id | No | Yes |
| Affects child processes | Yes — it is the environment | No |
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"What you learned
Section titled “What you learned”- Step outputs are written to
$GITHUB_OUTPUTasname=valueand read viasteps.<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.
fromJSONturns a string output into structured data, which is how dynamic matrices work.- Outputs are size-limited and unmasked; large values and secrets belong elsewhere.
Choosing between outputs and artifacts
Section titled “Choosing between outputs and artifacts”Both move data between jobs. Which one depends on what the data is.
| Output | Artifact | |
|---|---|---|
| Carries | A string | Files |
| Size | Small; truncated beyond a limit | Large, within storage limits |
| Persists after the run | No | Yes, until retention expires |
| Downloadable by a human | No | Yes |
| Requires an action | No | Upload and download actions |
| Cost | None | Storage |
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.
Debugging an empty output
Section titled “Debugging an empty output”Outputs fail silently, so a systematic check beats guessing.
- Does the producing step have an
id? Without one there is nothing to reference and the expression yields an empty string. - Is the syntax
name=valueappended to$GITHUB_OUTPUT? Notexport, notechoto stdout, not the retired::set-output. - Does the name match exactly? Output names are case-sensitive in practice; a mismatch is invisible.
- For a job output, is it declared in the job’s
outputs:block? Step outputs stop at the job boundary. - Does the consuming job list the producer in
needs? Without it,needs.<job>does not exist. - 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.
Outputs from actions
Section titled “Outputs from actions”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:
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.
Summary of the mechanisms
Section titled “Summary of the mechanisms”Five ways to move something, and choosing correctly is most of the difficulty:
| Move | From → to | Mechanism |
|---|---|---|
| A value | Step → later step | $GITHUB_ENV |
| A value | Step → workflow logic | $GITHUB_OUTPUT |
| A value | Job → dependent job | Job outputs |
| A value | Called workflow → caller | workflow_call outputs |
| Files | Job → job, or out of the run | Artifacts |
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.
Summary
Section titled “Summary”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.