Chapter 05Lesson 02~150 minutes

Environment Variables, Configuration Variables, Outputs, and Data Passing: Guided Hands-On Workflow

This guided workflow makes every scope change observable. You will start with static workflow configuration, optionally supply a repository configuration variable, override one value at job and step scope, create a derived value through GITHUB_ENV, publish a step output through GITHUB_OUTPUT, map it to a job output, and consume it from a second job. The exercise is intentionally small so every value has a visible producer and consumer.

Hands-onScope precedenceEnvironment filesneeds outputsMultiline

Learning objectives

  • Build one workflow progressively from workflow env through step/job outputs without hiding dataflow in external actions.
  • Demonstrate step-over-job-over-workflow environment precedence with bounded log evidence.
  • Use an optional repository configuration variable safely with an explicit fallback path.
  • Write and consume GITHUB_ENV and GITHUB_OUTPUT values, including a controlled multiline value.
  • Pass a named job output across needs and explain why a generated file would require separate file/artifact transport.

1. Disposable lab and preflight

Create a throwaway repository such as gha-dataflow-lab. The mandatory lab needs only GitHub-hosted ubuntu-24.04 and shell commands. Do not add secrets, PATs, cloud credentials, releases, packages, environments, or self-hosted runners.

StateExpected value
Workflow path.github/workflows/ch05-dataflow.yml
Triggerworkflow_dispatch
Permissions{}
Runnerubuntu-24.04
Secrets/credentialsNone
Optional repository variableLAB_CHANNEL=repository-demo; if absent, workflow falls back to not-configured.

2. Build the workflow progressively

name: Chapter 05 dataflow lab
on:
  workflow_dispatch:
    inputs:
      suffix:
        description: 'Synthetic suffix'
        type: string
        required: true
        default: 'alpha'
permissions: {}

env:
  APP_NAME: dataflow-lab
  SCOPE_MARKER: workflow

jobs:
  produce:
    runs-on: ubuntu-24.04
    env:
      SCOPE_MARKER: job
      CONFIG_CHANNEL: ${{ vars.LAB_CHANNEL || 'not-configured' }}
    outputs:
      release_label: ${{ steps.compute.outputs.release_label }}
      line_count: ${{ steps.compute.outputs.line_count }}
    steps:
      - name: Step 1 - inspect scope and write same-job env
        env:
          SCOPE_MARKER: step
        run: |
          printf 'app=%s scope=%s config=%s\n' "$APP_NAME" "$SCOPE_MARKER" "$CONFIG_CHANNEL"
          echo 'DERIVED_STAGE=verified' >> "$GITHUB_ENV"
          printf 'producer-step-derived=%q\n' "${DERIVED_STAGE-}"

      - name: Step 2 - consume env and publish outputs
        id: compute
        env:
          SUFFIX: ${{ inputs.suffix }}
        run: |
          printf 'derived_stage=%s\n' "$DERIVED_STAGE"
          printf 'release_label=%s-%s\n' "$APP_NAME" "$SUFFIX" >> "$GITHUB_OUTPUT"
          echo 'line_count=3' >> "$GITHUB_OUTPUT"
          {
            echo 'NOTE<> "$GITHUB_ENV"

      - name: Step 3 - verify same-job values
        env:
          LABEL: ${{ steps.compute.outputs.release_label }}
          COUNT: ${{ steps.compute.outputs.line_count }}
        run: |
          printf 'label=%s count=%s stage=%s\n' "$LABEL" "$COUNT" "$DERIVED_STAGE"
          printf 'note=%s\n' "$NOTE"
          printf 'run=%s attempt=%s sha=%s\n' "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" "$GITHUB_SHA"

  consume:
    needs: produce
    runs-on: ubuntu-24.04
    steps:
      - name: Consume explicit cross-job interface
        env:
          LABEL: ${{ needs.produce.outputs.release_label }}
          COUNT: ${{ needs.produce.outputs.line_count }}
        run: |
          printf 'received_label=%s received_count=%s\n' "$LABEL" "$COUNT"
          printf 'consumer local DERIVED_STAGE=%q\n' "${DERIVED_STAGE-}"

The last line is deliberate evidence: DERIVED_STAGE is empty in the consumer because it came from GITHUB_ENV in the producer job. Only the mapped job outputs cross the boundary.

3. Predict before dispatch

ObservationPredictionWhy
Step 1 SCOPE_MARKERstepStep env overrides job/workflow.
Step 1 immediate DERIVED_STAGEEmptyThe writer step does not see its new GITHUB_ENV value.
Steps 2/3 DERIVED_STAGEverifiedSubsequent steps in same job receive it.
Consumer DERIVED_STAGEEmptyDifferent job/runner.
Consumer LABELdataflow-lab-<suffix>Explicit step→job→needs output chain.
CONFIG_CHANNELRepository value or not-configuredOptional vars with explicit fallback.

4. Dispatch and capture run identity

Dispatch with suffix=alpha. Record the run ID, attempt, event name, source SHA, workflow SHA, job IDs/names, and runner OS. Preserve these values alongside the exact workflow revision before editing anything.

If you configured LAB_CHANNEL, verify its value appears only as ordinary unmasked configuration. Do not place any sensitive test value in it.

5. Prove environment precedence and same-job propagation

Use the logs to reconcile each prediction. The evidence should show scope=step in Step 1, an empty immediate DERIVED_STAGE, then derived_stage=verified in Step 2 and Step 3. This is more useful than memorizing “GITHUB_ENV works”: you have proven exactly when the runner injects the value.

6. Prove the explicit step → job → downstream-job chain

The producer publishes release_label and line_count through GITHUB_OUTPUT. The job maps those names in outputs:. The consumer can then read only what the job interface exposes through needs.produce.outputs.*.

This is intentionally different from reaching into another job's filesystem or process environment. The dependency edge and output names are visible in code review.

7. Multiline environment values: delimiter safety matters

The NOTE example uses a fixed delimiter only because the payload is fully synthetic and known not to contain that delimiter on a line by itself. For arbitrary external data, current GitHub guidance warns not to rely on delimiter framing if a collision is possible; write arbitrary content to a file instead.

That distinction also protects you from turning an untrusted payload into runner control syntax. Keep external text as data, quote it, and prefer files for opaque content.

8. Output or artifact? Apply a transport test

Imagine Step 2 also creates report.json containing 8 MB of structured evidence. Do not encode it into GITHUB_OUTPUT. The correct design is a file/artifact path because it is larger, file-shaped, and may need retention/download. Keep only a small identifier or digest as an output if downstream control flow needs one.

DataTransportReason
release_labelJob outputSmall explicit control value.
line_countJob outputSmall scalar consumed by logic.
8 MB report.jsonArtifact/fileFile-shaped evidence, not control-plane scalar data.
Password/tokenSecret/protected credential mechanismSensitive; neither vars nor plain output.

9. Challenge: add one new value without hidden coupling

Add a synthetic build_kind value. Decide first whether it is static workflow configuration, centrally managed configuration, a runtime step output, or a file. Then implement only the smallest channel that matches your classification and state which scope owns it.

A correct answer is not one specific keyword; it is a defensible value contract with producer, consumer, lifetime, and evidence.

10. Verification and cleanup

  • Confirm no secret or credential was configured or logged.
  • Confirm permissions: {} remained unchanged.
  • Confirm consumer receives only named job outputs, not producer ambient state.
  • Record the workflow revision and run ID/attempt before cleanup.
  • Remove the optional LAB_CHANNEL repository variable if you created it solely for the disposable lab.
  • Delete only learner-owned disposable resources after retaining evidence as long as needed.
Next lesson

Design patterns and trade-offs

Choose between env, vars, outputs, artifacts, and explicit interfaces based on scope, security, size, auditability, and reuse.

Knowledge check

Why is DERIVED_STAGE empty in the consumer job?

What proves release_label crossed jobs intentionally?

Why is the optional LAB_CHANNEL safe only for non-sensitive data?

Why should an arbitrary multiline payload be written to a file instead of delimiter-encoded into GITHUB_ENV?

What should transport an 8 MB report to another job?

Official references and version notes

Version and compatibility note

Version-sensitive variable/output behavior was rechecked against current primary GitHub documentation on 2026-09-09. Executable labs use a disposable repository, ubuntu-24.04, built-in Bash steps only, and explicit permissions: {}; no external action, secret, package, deployment, self-hosted runner, or cloud account is required. At verification time, workflow/job/step env uses the most specific scope; default GITHUB_*/RUNNER_* environment variables cannot be overwritten; the step that writes GITHUB_ENV does not see the new value but later steps in the same job do; GITHUB_ENV cannot set NODE_OPTIONS; GITHUB_OUTPUT is the current step-output channel; multiline environment/output values use delimiter syntax whose delimiter must not occur on a line by itself; and arbitrary data is safer as a file rather than delimiter-encoded text. Configuration variables are non-secret, have organization/repository/environment scopes, and are subject to current limits including 48 KB per variable, 500 repository variables, 1,000 organization variables, 100 environment variables, and a 256 KB combined repository+organization payload per workflow run. Job outputs are limited to 1 MB per job and 50 MB total per workflow run (size approximated using UTF-16), so large/binary evidence belongs in files/artifacts rather than outputs. GitHub Actions is continuously delivered, so regenerate only after rechecking these limits and commands. The optional repository variable is a disposable-repository configuration exercise only; the workflow has a no-variable fallback so the mandatory lab remains simple and free-compatible.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.