3.03 Job Outputs
Overview
Artifacts move files between jobs. Outputs move values — a filename, a version number, an image tag — from a step to its job, and from that job to the jobs that depend on it.
Abstract
A step writes a key-value pair to the file at $GITHUB_OUTPUT and declares an id so it can be
referenced. The job then re-exports that value through a top-level outputs: key, reading it
with ${{ steps.<id>.outputs.<name> }}. Downstream jobs read it as
${{ needs.<job>.outputs.<name> }}. The older ::set-output workflow command does the same
thing and still appears in tutorials, but it is deprecated and should not be used in new
workflows.
Why It Matters in Production
Plenty of pipeline values only exist at build time: a hashed asset filename, a generated version string, a container image digest, the ID of a deployment just created. Recomputing them in each job means duplicating logic and risking drift; outputs compute once and pass the result along.
They are also the contract boundary for composition. A reusable workflow or a custom action publishes outputs the same way, so understanding this mechanism is the prerequisite for building either.
Key Concepts
| Concept | Meaning |
|---|---|
| Step output | A key-value pair written to $GITHUB_OUTPUT by one step |
id on a step |
Required handle for referencing that step's outputs |
steps context |
steps.<id>.outputs.<name> — readable within the same job only |
Job outputs: |
Top-level key re-exporting step values as the job's outputs |
needs context |
needs.<job-id>.outputs.<name> — how downstream jobs read them |
$GITHUB_OUTPUT |
Path to a GitHub-managed file the runner collects after each step |
Outputs vs artifacts
| Output | Artifact | |
|---|---|---|
| Carries | A simple value: text or a number | Files and folders |
| Mechanism | $GITHUB_OUTPUT + needs context |
upload-artifact / download-artifact |
| Size | Small (kilobytes) | Up to gigabytes |
| Stored after the run | No | Yes, until retention expires |
| Downloadable by a human | No | Yes |
Two naming layers, deliberately separate
The step's output name and the job's output name are independent. The step name is local to
the step; the job name is what other jobs see. They are commonly kept identical for sanity, but
nothing requires it — the outputs: block is where one is mapped to the other.
::set-output is deprecated
echo "::set-output name=script-file::value" was the old syntax and is disabled on current
runners. Use echo "script-file=value" >> "$GITHUB_OUTPUT" instead. Older course videos, blog
posts, and Stack Overflow answers still show the old form.
Common Use Cases
- Publish a hashed or generated build filename for a later deploy step
- Pass a computed version or tag from a version job to build and publish jobs
- Hand a container image digest from build to a deployment job
- Share a deployment URL or preview link produced by an infrastructure job
- Expose a changed-files list that downstream jobs branch on
- Return results from a reusable workflow or custom action
Example Configuration or Commands
The build job computes the generated JavaScript filename and publishes it as a job output:
jobs:
build:
needs: test
runs-on: ubuntu-latest
outputs:
script-file: ${{ steps.publish.outputs.script-file }}
steps:
- name: Get code
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Build website
run: npm run build
- name: Publish JS filename
id: publish # required to reference this step
run: find dist/assets/*.js -type f -execdir echo 'script-file={}' >> $GITHUB_OUTPUT ';'
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dist-files
path: dist
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Get build artifacts
uses: actions/download-artifact@v4
with:
name: dist-files
- name: Output filename
run: echo "${{ needs.build.outputs.script-file }}"
- name: Deploy
run: echo "Deploying..."
The find command locates the JavaScript file in dist/assets/ and appends
script-file=<filename> to the output file. Run without the redirect it simply prints the name —
the >> $GITHUB_OUTPUT part is what turns printing into publishing.
jobs:
build:
outputs:
# 3. job output <- 2. step output, via the step's id
script-file: ${{ steps.publish.outputs.script-file }}
steps:
- id: publish
# 1. step writes key=value to the special file
run: echo "script-file=index-abc123.js" >> "$GITHUB_OUTPUT"
deploy:
needs: build
steps:
# 4. downstream job reads it through the needs context
- run: echo "${{ needs.build.outputs.script-file }}"
Output from an action, not a script
Actions publish outputs through the same mechanism, which is why the access pattern is identical:
Checking what a job actually produced:
# Outputs appear in the run's job data
gh run view <run-id> --json jobs --jq '.jobs[] | {name, conclusion}'
# Simplest check: echo it in a downstream step and read the log
gh run view <run-id> --log | grep -A2 "Output filename"
Best Practices
Best Practices
- Use an output for a value and an artifact for a file. Writing a filename into a text file and uploading it is the long way round.
- Keep step and job output names identical unless there's a reason not to — two names for one value invites mismatch.
- Quote the redirect target:
>> "$GITHUB_OUTPUT". The unquoted form works until a path contains a space. - Give outputs descriptive names (
script-file,image-tag), notresultorvalue. - Declare every output a downstream job needs at the job level; the
stepscontext does not cross job boundaries. - Prefer computing a value once and passing it on, rather than recomputing it per job.
- Use the heredoc delimiter form for anything that might contain a newline.
- Treat outputs as strings —
if: needs.build.outputs.count > 0compares text, so convert explicitly where it matters.
Security Best Practices
Security
- Never put a secret in an output. Outputs are stored in run metadata and routinely echoed in later steps; GitHub does not mask values that arrive this way unless they exactly match a registered secret. Pass secrets as secrets.
- A value derived from a secret (a token's prefix, a base64 variant) is not masked either. Don't round-trip secrets through outputs to reshape them.
- Outputs built from untrusted input — a PR title, a branch name, a commit message — end up
interpolated into downstream
runcommands. Pass them throughenv:and quote them, exactly as with any other context value. - Writing arbitrary command output to
$GITHUB_OUTPUTcan inject extra key-value lines if the command's output contains newlines. Use the delimiter form so the content cannot break out. - Outputs from a job that failed or was skipped are empty. Check the value rather than assuming it exists, or a deploy step silently acts on an empty string.
- Keep least-privilege
permissions:; outputs change what a job knows, never what it may do.
Do and Don't
| ✅ Do | ❌ Don't |
|---|---|
echo "key=value" >> "$GITHUB_OUTPUT" |
Use ::set-output |
Give the step an id |
Reference a step with no id |
Re-export via the job's outputs: block |
Expect steps context to work across jobs |
Read downstream as needs.<job>.outputs.<name> |
Read steps.<id>.outputs from another job |
| Use outputs for values | Use artifacts to shuttle a single string |
| Use the delimiter form for multi-line data | Echo multi-line text as a plain key=value |
| Validate an output before acting on it | Assume a failed job still produced it |
| Keep secrets out of outputs | Pass a token between jobs as an output |
Common Mistakes
Common Mistakes
- Missing
id. Without it there is no handle, andsteps..outputsresolves to nothing. - Skipping the job-level
outputs:block. The step output exists, but nothing outside the job can see it. - Name mismatch. The name after
steps.<id>.outputs.must match the key written to$GITHUB_OUTPUTexactly, hyphens included. - Using
stepscontext in another job. It is job-scoped; downstream jobs useneeds. - Forgetting
needs. Without it,needs.build.outputs.*is unavailable even if the job ran. - Single
>instead of>>. Truncates the file and discards other outputs written by the same step. - Spaces around the equals sign.
key = valuedoes not parse; writekey=value. - Expecting a value from a matrix job. Parallel matrix instances overwrite each other's outputs non-deterministically — use artifacts or distinct job names instead.
- Assuming outputs are typed. Everything is a string.
Breaks in production
A deploy job reading needs.build.outputs.image-tag when the build job was skipped gets an
empty string, and docker pull myapp: resolves to latest. The pipeline is green and the
wrong image is live.
Troubleshooting
# Confirm what the step wrote — temporarily echo the file back
# (add as a step right after the publishing step)
# run: cat "$GITHUB_OUTPUT"
# Verify the value reached the downstream job
gh run view <run-id> --log | grep -A2 "Output filename"
# Check the find command's behavior locally before committing
find dist/assets/*.js -type f -execdir echo 'script-file={}' ';'
| Symptom | Likely cause |
|---|---|
| Output is empty downstream | Missing job-level outputs: block |
| "Unrecognized named-value: 'steps'" in another job | steps context used outside its job |
needs.build.outputs.x empty |
Missing needs, name mismatch, or build job skipped/failed |
| Value prints in the step but not later | Written to stdout instead of $GITHUB_OUTPUT |
| Only the last output survives | Single > truncated the file |
::set-output has no effect |
Deprecated and disabled — switch to $GITHUB_OUTPUT |
| Multi-line value truncated at the first newline | Needs the heredoc delimiter form |
| Output differs between runs of a matrix job | Matrix instances share one job output slot |
Tip
The quickest way to debug an output is to echo it in the consuming job before using it. A
one-line run: echo "got: ${{ needs.build.outputs.script-file }}" distinguishes "never
published" from "published under a different name".
Quick Recap
- Outputs carry simple values; artifacts carry files.
- A step writes
key=valueto$GITHUB_OUTPUTand must declare anid. - The job re-exports it through a top-level
outputs:key using${{ steps.<id>.outputs.<name> }}. - Step output names and job output names are independent, though usually kept the same.
- Downstream jobs read
${{ needs.<job-id>.outputs.<name> }}and must declareneeds. - Use
>>, not>, and quote"$GITHUB_OUTPUT". - Multi-line values need the heredoc delimiter form.
::set-outputis deprecated; avoid it in new workflows.- Outputs are strings, are not masked, and are empty when the producing job didn't succeed.
Interview / Revision Notes
Quick revision
Q: Output vs artifact? An output is a simple value passed between jobs; an artifact is files stored and downloadable.
Q: How does a step publish an output?
echo "name=value" >> "$GITHUB_OUTPUT".
Q: What makes a step referenceable?
An id: field.
Q: How do you read a step's output in the same job?
${{ steps.<id>.outputs.<name> }}.
Q: How do you expose it to other jobs?
Declare it under the job's top-level outputs: key.
Q: How does a downstream job read it?
${{ needs.<job-id>.outputs.<name> }}, with needs declared.
Q: What is $GITHUB_OUTPUT?
The path to a GitHub-managed file the runner reads after each step to collect outputs.
Q: What replaced ::set-output?
Writing to $GITHUB_OUTPUT; the old workflow command is deprecated.
Q: Can you put a secret in an output? No — outputs aren't reliably masked and are echoed freely downstream.
Q: What type are outputs? Always strings.
This topic can be expanded into sub-pages: environment files (
$GITHUB_ENV,$GITHUB_PATH,$GITHUB_STEP_SUMMARY), outputs from custom actions, reusable workflow outputs, conditional jobs driven by outputs, and matrix strategies with per-instance results.