Skip to content

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 }}"
- name: Get version
  id: version
  run: echo "value=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"

- name: Use it
  run: echo "Building version ${{ steps.version.outputs.value }}"
jobs:
  build:
    outputs:
      script-file: ${{ steps.publish.outputs.script-file }}
      version: ${{ steps.version.outputs.value }}
      image-tag: ${{ steps.meta.outputs.tag }}
# A plain key=value line cannot hold newlines; use a random delimiter
- name: Collect notes
  id: notes
  run: |
    {
      echo 'body<<EOF'
      git log --oneline -10
      echo EOF
    } >> "$GITHUB_OUTPUT"
# Old form, still visible in older material — disabled on current runners
- run: echo "::set-output name=script-file::index-abc123.js"

Output from an action, not a script

Actions publish outputs through the same mechanism, which is why the access pattern is identical:

- name: Upload artifact
  id: upload
  uses: actions/upload-artifact@v4
  with:
    name: dist-files
    path: dist
- run: echo "Artifact URL ${{ steps.upload.outputs.artifact-url }}"

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), not result or value.
  • Declare every output a downstream job needs at the job level; the steps context 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 > 0 compares 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 run commands. Pass them through env: and quote them, exactly as with any other context value.
  • Writing arbitrary command output to $GITHUB_OUTPUT can 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, and steps..outputs resolves 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_OUTPUT exactly, hyphens included.
  • Using steps context in another job. It is job-scoped; downstream jobs use needs.
  • 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 = value does not parse; write key=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=value to $GITHUB_OUTPUT and must declare an id.
  • 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 declare needs.
  • Use >>, not >, and quote "$GITHUB_OUTPUT".
  • Multi-line values need the heredoc delimiter form.
  • ::set-output is 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.