skip to content

How does one GitHub Actions job pass a computed value to a later job?

level: middleimportance: must knowfreq 66%

answer

  1. strings only, never files
  2. three hops, not one
  3. the producing step needs an id
  4. the job must republish it
  5. the reader must declare the edge

basics

~10 s

A step appends name=value to the file at $GITHUB_OUTPUT and carries an id. The job republishes it under its outputs: map, and any job that lists it in needs: reads it as needs.<job_id>.outputs.<name>.

solid answer

~40 s

It is a three-hop chain in GitHub Actions. First the producing step writes `echo "version=1.4.2" >> "$GITHUB_OUTPUT"` and must have an `id:`, which makes the value readable as `steps.<id>.outputs.version` **within that job only**. Second, the job republishes it: `outputs: { version: "${{ steps.<id>.outputs.version }}" }`. Third, the consumer declares `needs: [that_job]` and reads `${{ needs.that_job.outputs.version }}` — without the `needs:` edge the value is simply unavailable. Two traps: values that GitHub recognises as secrets are redacted out of job outputs, and all jobs of a matrix write the same output names, so the last one to finish overwrites the rest. Files never travel this way — outputs are strings, so build artefacts go through `actions/upload-artifact` instead.

code

yaml · 20 lines
yaml
jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.meta.outputs.version }}
      targets: ${{ steps.meta.outputs.targets }}
    steps:
      - uses: actions/checkout@v4
      - id: meta
        run: |
          echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT"
          echo 'targets=["staging","canary"]' >> "$GITHUB_OUTPUT"
  deploy:
    needs: [build]
    runs-on: ubuntu-latest
    strategy:
      matrix:
        target: ${{ fromJSON(needs.build.outputs.targets) }}
    steps:
      - run: ./deploy.sh "${{ matrix.target }}" "${{ needs.build.outputs.version }}"

go deeper

for a junior

Recall the shape: a step appends name=value to $GITHUB_OUTPUT, the job lists it under outputs:, and a job that needs: it reads needs.<job_id>.outputs.<name>. Remember the producing step must carry an id.

for a middle

Explain why three hops exist — separate runners, no shared state — and the difference between $GITHUB_OUTPUT, $GITHUB_ENV, and $GITHUB_STEP_SUMMARY. Be able to debug the empty-string case caused by a missing needs: edge.

for a senior

Bring the operational traps unprompted: secret redaction, matrix jobs overwriting one another, string-only typing, and the JSON-plus-fromJSON pattern for structured hand-off. Know when an artifact is the right channel instead.

for a principal

Decide what the contract between pipeline stages should be at all: which values are legitimate coupling between jobs, which belong in an artifact or a registry, and how to keep downstream jobs from silently deploying an empty version string.

## Why a chain and not a variable Jobs in a GitHub Actions workflow run on separate runners with separate filesystems and separate processes. Nothing a job computes is visible anywhere else unless the job *publishes* it. The publishing mechanism is deliberately narrow — it moves strings, not state — and it has three hops. ## Hop 1: step output A step writes to the file whose path is in the `GITHUB_OUTPUT` environment variable: ``` - id: meta run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT" ``` Two requirements. The step needs an `id:` — without one there is no name to read it back by. And the write must **append** (`>>`), because the runner may already have content in that file from earlier writes in the same step. Other steps in the same job now read `${{ steps.meta.outputs.version }}`. This is job-local: the `steps` context does not cross a job boundary. For a multi-line value, use the delimiter form rather than trying to echo newlines: ``` { echo "notes<<EOF" cat CHANGELOG.md echo "EOF" } >> "$GITHUB_OUTPUT" ``` An action invoked with `uses:` sets its outputs the same way internally, so `steps.<id>.outputs.<name>` works identically for action steps — the action's `action.yml` documents which names exist. ## Hop 2: job output A job publishes selected step outputs through its own `outputs:` map: ``` jobs: build: runs-on: ubuntu-latest outputs: version: ${{ steps.meta.outputs.version }} steps: - id: meta run: echo "version=1.4.2" >> "$GITHUB_OUTPUT" ``` The keys under `outputs:` are the *public* names — they need not match the step output names. The expressions are evaluated at the end of the job, which means a job that never reached that step publishes an empty string rather than failing. ## Hop 3: the consumer The consuming job must declare the dependency; the `needs` context only contains jobs the job actually needs: ``` deploy: needs: [build] runs-on: ubuntu-latest steps: - run: ./deploy.sh "${{ needs.build.outputs.version }}" ``` Omitting `needs: [build]` does not produce a helpful error — the expression simply evaluates to an empty string, and the script gets an empty argument. That silent-empty failure mode is the single most common bug with this feature, and the debugging move is to echo the value first. ## The traps worth naming in an interview **Secrets are redacted.** If a value the job tries to publish matches a secret, GitHub removes it from the job output. Passing a credential from job to job this way does not work and is not supposed to; the consumer should read the secret itself. **Matrix jobs collide.** Every job generated by a matrix shares one job id, so all of them write the same output names and the last to finish wins, with no defined ordering. If you need per-variant values, write them to artifacts with variant-specific names, or aggregate in a single downstream job that downloads all of them. **Outputs are strings.** There are no numbers, booleans, or objects. `if: needs.build.outputs.publish == 'true'` compares strings — the quoting matters. To move structured data, publish JSON as a string and parse it with `fromJSON` on the other side, which is also how a downstream matrix can be built from an upstream job's computation. **Outputs are not files.** A jar, a coverage report, or a container tarball goes through `actions/upload-artifact` in the producer and `actions/download-artifact` in the consumer. Outputs carry only the *name* of such a thing. ## Contrast with $GITHUB_ENV Beginners reach for `$GITHUB_ENV` when they mean `$GITHUB_OUTPUT`. Appending to `$GITHUB_ENV` sets an environment variable for **subsequent steps of the same job** — useful, but it stops at the job boundary and cannot be republished as a job output directly. `$GITHUB_OUTPUT` is the one that participates in the three-hop chain. A third file, `$GITHUB_STEP_SUMMARY`, is for human-readable Markdown shown on the run page and carries no data at all.

  • In GitHub Actions, what is the difference between writing to $GITHUB_OUTPUT and $GITHUB_ENV?
    `$GITHUB_OUTPUT` records a named result for the step, readable as `steps.<id>.outputs.<name>` and republishable as a job output for downstream jobs. `$GITHUB_ENV` sets an environment variable for later steps in the same job only; it never crosses a job boundary and cannot be referenced through the `needs` context. Use OUTPUT for values that must travel, ENV for convenience within the job.
  • Why do job outputs behave unpredictably when the producing job uses a matrix?
    All variants of a matrix share one job id and therefore one set of output names, so each finishing variant overwrites the previous one and no ordering is guaranteed. The downstream job sees whichever wrote last. Publish per-variant data as artifacts with distinct names instead, or have a single aggregating job read them and emit one authoritative output.
  • How would you pass a list of values from one GitHub Actions job to a matrix in the next?
    Emit the list as a JSON string from the first job — for example `echo "targets=[\"a\",\"b\"]" >> "$GITHUB_OUTPUT"` — republish it as a job output, then have the consumer declare `needs:` on it and expand it with `fromJSON(needs.plan.outputs.targets)`. Outputs are always strings, so JSON plus `fromJSON` is the supported way to move structure.
  • A downstream GitHub Actions job reads an empty output. What do you check first?
    Whether the consumer actually lists the producer in `needs:` — without the edge the `needs` context has no entry and the expression silently resolves to an empty string. Then check that the producing step has an `id:`, that the job's `outputs:` map references that exact id and name, that the step really ran, and that the value was not redacted as a secret.

saying these in an interview costs you the question

  • Expects steps.<id>.outputs to work across jobs
  • Uses $GITHUB_ENV and expects a downstream job to see it
  • Reads needs.<job>.outputs without declaring needs:
  • Tries to pass a built file as a job output
  • Treats outputs as typed booleans rather than strings

context