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.
Learning objectives
- Build one workflow progressively from workflow
envthrough 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_ENVandGITHUB_OUTPUTvalues, including a controlled multiline value. - Pass a named job output across
needsand 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.
| State | Expected value |
|---|---|
| Workflow path | .github/workflows/ch05-dataflow.yml |
| Trigger | workflow_dispatch |
| Permissions | {} |
| Runner | ubuntu-24.04 |
| Secrets/credentials | None |
| Optional repository variable | LAB_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
| Observation | Prediction | Why |
|---|---|---|
Step 1 SCOPE_MARKER | step | Step env overrides job/workflow. |
Step 1 immediate DERIVED_STAGE | Empty | The writer step does not see its new GITHUB_ENV value. |
Steps 2/3 DERIVED_STAGE | verified | Subsequent steps in same job receive it. |
Consumer DERIVED_STAGE | Empty | Different job/runner. |
Consumer LABEL | dataflow-lab-<suffix> | Explicit step→job→needs output chain. |
CONFIG_CHANNEL | Repository value or not-configured | Optional 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.
| Data | Transport | Reason |
|---|---|---|
release_label | Job output | Small explicit control value. |
line_count | Job output | Small scalar consumed by logic. |
8 MB report.json | Artifact/file | File-shaped evidence, not control-plane scalar data. |
| Password/token | Secret/protected credential mechanism | Sensitive; 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_CHANNELrepository variable if you created it solely for the disposable lab. - Delete only learner-owned disposable resources after retaining evidence as long as needed.
Knowledge check
Why is DERIVED_STAGE empty in the consumer job?
It was written through GITHUB_ENV in the producer job, which only affects subsequent steps in that same job.
What proves release_label crossed jobs intentionally?
The step output is mapped into produce.outputs, and the consumer declares needs: produce and reads needs.produce.outputs.release_label.
Why is the optional LAB_CHANNEL safe only for non-sensitive data?
Configuration variables are unmasked ordinary configuration, not a secret store.
Why should an arbitrary multiline payload be written to a file instead of delimiter-encoded into GITHUB_ENV?
A delimiter collision can corrupt framing; current guidance recommends a file for completely arbitrary data.
What should transport an 8 MB report to another job?
A file/artifact mechanism, while a small identifier or digest can remain an output.
Official references and version notes
- Variables — current distinction between workflow
env, configuration variables, default variables, and secrets. - Variables reference — current default variables, naming rules, precedence, configuration-variable limits, and context guidance.
- Store information in variables — current examples for workflow/job/step environment variables, configuration variables, contexts, and cross-step/job data flow.
- Workflow commands for GitHub Actions — current environment files including
GITHUB_ENV,GITHUB_OUTPUT, multiline syntax, and restrictions. - Passing information between jobs — current job-output mapping and
needs.<job>.outputsconsumption. - Workflow syntax for GitHub Actions — current
env, job outputs,needs, output size limits, and workflow syntax. - Store and share data with workflow artifacts — current artifact model for files that must outlive a step/job filesystem or are inappropriate for small outputs.
- GitHub Actions changelog: set-output update — why new workflow code should use environment files rather than the deprecated stdout
set-outputcommand.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.