Workflows deal with two kinds of value, and conflating them is a security problem rather than a style one.
Variables are configuration: a region, a log level, an environment name. Visible in logs, stored in plain text, fine for anyone to read.
Secrets are credentials: tokens, keys, passwords. Masked in logs, encrypted at rest, and covered in Secrets.
This lesson is about the first. The distinction is the last section, because it is the one that matters most.
env at three scopes
Section titled “env at three scopes”env: APP_NAME: my-service # every job, every step
jobs: build: runs-on: ubuntu-latest env: BUILD_MODE: release # every step in this job steps: - run: echo "$APP_NAME in $BUILD_MODE"
- run: echo "$LOG_LEVEL" env: LOG_LEVEL: debug # this step onlyNarrower scopes win. A LOG_LEVEL set at all three levels resolves to the step’s value inside that
step, the job’s elsewhere in the job, and the workflow’s elsewhere.
Precedence, most specific first:
- Step
env - Job
env - Workflow
env - Default environment variables set by the runner
Default environment variables
Section titled “Default environment variables”Every runner has variables describing the run. The ones used constantly:
| Variable | Contains |
|---|---|
GITHUB_REPOSITORY | owner/repo |
GITHUB_SHA | The commit SHA being built |
GITHUB_REF | The full ref — refs/heads/main |
GITHUB_REF_NAME | The short name — main |
GITHUB_EVENT_NAME | What triggered this — push, pull_request |
GITHUB_ACTOR | Who triggered it |
GITHUB_RUN_ID | This run’s identifier |
GITHUB_WORKSPACE | Where the repository is checked out |
RUNNER_OS | Linux, Windows, macOS |
RUNNER_TEMP | A temporary directory cleaned up afterwards |
- run: | echo "Building $GITHUB_REPOSITORY at $GITHUB_SHA" echo "Triggered by $GITHUB_EVENT_NAME on $GITHUB_REF_NAME"Most also have a context equivalent —
github.sha, github.ref_name. The difference matters: the environment variable is read by the shell
at runtime, the context is substituted by GitHub before the shell runs.
Passing values between steps
Section titled “Passing values between steps”Each run: step is a separate shell process, so this does not work:
- run: export VERSION=1.4.0- run: echo "$VERSION" # empty$GITHUB_ENV is a file the runner reads after each step. Appending to it sets a variable for
subsequent steps:
- run: echo "VERSION=1.4.0" >> "$GITHUB_ENV"- run: echo "Version is $VERSION" # 1.4.0Not for the step that set it — only later ones.
Multiline values need a delimiter, and the delimiter must not appear in the value:
- run: | { echo "NOTES<<MGA_EOF" cat CHANGELOG.md echo "MGA_EOF" } >> "$GITHUB_ENV"There is also $GITHUB_PATH, which prepends a directory to PATH for later steps:
- run: echo "$HOME/.local/bin" >> "$GITHUB_PATH"Configuration variables
Section titled “Configuration variables”Beyond env, GitHub stores variables at repository, organisation and environment level, read
through the vars context.
jobs: deploy: runs-on: ubuntu-latest environment: production steps: - run: ./deploy.sh env: REGION: ${{ vars.AWS_REGION }} CLUSTER: ${{ vars.CLUSTER_NAME }}Managed like secrets, without the encryption:
gh variable set AWS_REGION --body "eu-west-1"gh variable set CLUSTER_NAME --body "prod-cluster" --env productiongh variable listgh variable list --env productionThe scopes resolve most-specific-first: environment, then repository, then organisation.
An AWS_REGION defined at organisation level and overridden for the production environment
resolves to the environment’s value in a job targeting it.
That layering is what makes vars more useful than env for anything differing per environment —
one workflow, values that change with the target, no conditionals.
Variables versus secrets
Section titled “Variables versus secrets”The decision, stated plainly:
| Variable | Secret | |
|---|---|---|
| Stored | Plain text | Encrypted |
| Visible in the interface | Yes, value shown | No, name only |
| Masked in logs | No | Yes |
| Readable back | Yes | No |
| For | Configuration | Credentials |
If disclosure would matter, it is a secret. If it would not, it is a variable.
A region name is not a secret. A database password is. An internal hostname sits between, and the conservative answer is usually right.
Cross-platform differences
Section titled “Cross-platform differences”Variable syntax differs by shell, which matters in a matrix spanning operating systems.
- shell: bash run: echo "$APP_NAME"
- shell: pwsh run: Write-Output $env:APP_NAME
- shell: cmd run: echo %APP_NAME%Two ways to avoid the problem: set shell: bash everywhere, which works on GitHub-hosted Windows
runners; or use the context form, which GitHub substitutes before any shell sees it:
- run: echo "${{ env.APP_NAME }}" # same everywhereThe context form has its own caveat — it is substitution, not variable reference — so it must not be used for untrusted values.
Environment-scoped variables
Section titled “Environment-scoped variables”Variables attached to a deployment environment are the mechanism that lets one workflow deploy to several targets without conditionals.
jobs: deploy: runs-on: ubuntu-latest environment: ${{ inputs.environment }} steps: - run: ./deploy.sh env: REGION: ${{ vars.REGION }} CLUSTER: ${{ vars.CLUSTER_NAME }} REPLICAS: ${{ vars.REPLICA_COUNT }}gh variable set REGION --body "eu-west-1" --env staginggh variable set REGION --body "eu-west-2" --env productiongh variable set REPLICA_COUNT --body "2" --env staginggh variable set REPLICA_COUNT --body "12" --env productionOne job definition, values resolved by the environment it targets. Compare with the alternative — a
chain of if: conditions selecting values per branch — which duplicates the deployment logic once per
environment and drifts.
This is the strongest argument for environments beyond their protection rules: they are a namespace for configuration as well as a gate.
Variables in a matrix
Section titled “Variables in a matrix”Matrix values are available through the matrix context and are frequently combined with env:
strategy: matrix: include: - target: staging region: eu-west-1 - target: production region: eu-west-2
steps: - run: ./deploy.sh env: TARGET: ${{ matrix.target }} REGION: ${{ matrix.region }}include with several keys per entry is how a matrix carries related values rather than just a list —
covered in Matrix Builds.
Naming conventions
Section titled “Naming conventions”Small habits that prevent real confusion.
Prefix by scope where it helps. CI_LOG_LEVEL reads differently from APP_LOG_LEVEL, and in a
workflow that sets both, the distinction matters.
Avoid names the runner already uses. Anything starting GITHUB_ or RUNNER_ is reserved
territory. Setting GITHUB_TOKEN in an env: block shadows the real one in ways that are hard to
trace.
Use UPPER_SNAKE_CASE. Conventional for environment variables and consistent with what the runner
provides.
Do not encode secrets in names. PROD_DB_PASSWORD_2 as a variable name tells a reader more than
you intended about what exists.
When to use which
Section titled “When to use which”A summary, since there are now four mechanisms.
| Need | Use |
|---|---|
| A constant for the whole workflow | Workflow env |
| A value only one job needs | Job env |
| A value only one step needs | Step env |
| A value computed by a step, needed later | $GITHUB_ENV |
| Configuration differing per environment | Environment vars |
| Configuration shared across repositories | Organisation vars |
| Anything whose disclosure would matter | A secret |
| A value another job needs | A job output |
The two most common mistakes are using a secret for configuration — losing visibility for no benefit —
and using workflow env for something that differs per environment, which forces conditionals that
environment-scoped vars would remove.
Common mistakes
Section titled “Common mistakes”export between steps. Each step is a new shell; use $GITHUB_ENV.
Expecting $GITHUB_ENV to affect the current step. It applies to later ones.
A secret in a variable. Plain text, unmasked, visible.
Multiline without a delimiter. Breaks the file format.
Shell-specific syntax in a cross-platform matrix. Set shell: bash or use the context form.
Writing unvalidated event data to $GITHUB_ENV. Lets untrusted input define later variables.
env where vars belongs. Values differing per environment want environment-scoped variables.
Debugging variable problems
Section titled “Debugging variable problems”Variables fail quietly — an empty value rather than an error — so a systematic approach beats guessing.
- name: Dump environment run: env | sortThat prints everything the shell can see, including the defaults GitHub set. It is the fastest way to answer “is my variable there at all?”.
The checks, in order:
- Is the name spelled identically? Environment variables are case-sensitive on Linux and macOS.
- Is it in scope? A step-level
env:does not reach the next step. - Was it set with
exportin a previous step? That does not persist; use$GITHUB_ENV. - Is it shadowed? A narrower scope wins, and a step-level value overrides one from
$GITHUB_ENV. - For
vars, is the scope right? An environment variable is invisible to a job that does not declare that environment. - For
vars, is it defined at all? An undefinedvars.SOMETHINGis empty, not an error.
Item 5 is the most common vars problem: the variable exists, the job does not declare
environment:, and the reference resolves to nothing.
Variables and reusable workflows
Section titled “Variables and reusable workflows”A reusable workflow does not inherit the caller’s env
block. Values must be passed as inputs:
# Callerjobs: deploy: uses: ./.github/workflows/deploy.yml with: log-level: debug region: eu-west-1# Calledon: workflow_call: inputs: log-level: { type: string, required: false, default: info } region: { type: string, required: true }
jobs: run: runs-on: ubuntu-latest env: LOG_LEVEL: ${{ inputs.log-level }} REGION: ${{ inputs.region }} steps: - run: ./deploy.shOrganisation and repository vars, by contrast, are visible to a called workflow, because they
belong to the repository rather than to the calling workflow. That asymmetry catches people: env does
not cross the boundary, vars does.
Exercise
Section titled “Exercise”- Set the same variable name at workflow, job and step level, and print it from each scope.
- Try passing a value between steps with
export; confirm it is empty. - Do it with
$GITHUB_ENV, and confirm it is empty in the setting step and populated afterwards. - Append a multiline value using a heredoc delimiter.
- Create a repository variable with
gh variable setand read it throughvars. - Print
envon a Linux and a Windows runner in the same matrix and compare the syntax needed.
Runner-provided paths
Section titled “Runner-provided paths”Beyond the GITHUB_* values, the runner exposes several directories worth knowing:
| Variable | Contains |
|---|---|
GITHUB_WORKSPACE | Where the repository is checked out |
RUNNER_TEMP | Scratch space, cleaned up after the job |
RUNNER_TOOL_CACHE | Pre-installed language versions |
GITHUB_ACTION_PATH | An action’s own directory, inside that action |
RUNNER_TEMP is the correct place for anything transient — a downloaded archive, an intermediate file.
Writing to the workspace instead pollutes the checkout, which matters if a later step inspects
git status or uploads the directory.
GITHUB_ACTION_PATH is how a composite action references files that ship with it:
- shell: bash run: "${GITHUB_ACTION_PATH}/scripts/setup.sh"Without it, a relative path resolves against the caller’s working directory rather than the action’s own — which works while developing an action locally and breaks as soon as anyone else uses it.
What you learned
Section titled “What you learned”envapplies at workflow, job and step scope, narrowest winning.- Default variables describe the run; most have a context equivalent evaluated at a different time.
$GITHUB_ENVpasses values to later steps;$GITHUB_PATHextendsPATH.varsholds configuration at environment, repository and organisation scope, resolving most-specific-first.- Variables are plain text and unmasked; secrets are neither.
- Shell syntax for variables differs by platform, and the context form sidesteps it.
The three runner files
Section titled “The three runner files”$GITHUB_ENV is one of several files the runner reads between steps. Knowing all of them is
worthwhile because each solves a different problem.
| File | Effect |
|---|---|
$GITHUB_ENV | Sets environment variables for later steps |
$GITHUB_PATH | Prepends a directory to PATH for later steps |
$GITHUB_OUTPUT | Publishes a named output from this step |
$GITHUB_STEP_SUMMARY | Appends Markdown to the run’s summary page |
- run: | echo "VERSION=1.4.0" >> "$GITHUB_ENV" echo "$HOME/.local/bin" >> "$GITHUB_PATH" echo "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" echo "### Built version 1.4.0" >> "$GITHUB_STEP_SUMMARY"All four are append-only files, and all four apply to subsequent steps rather than the current one. That last property is consistent and is worth internalising once rather than rediscovering per file.
$GITHUB_PATH is the correct way to make an installed tool available. Modifying PATH with export
does not survive the step, and writing to $GITHUB_ENV with a PATH= line replaces the whole
variable rather than extending it — which breaks everything else on the runner.
A worked precedence example
Section titled “A worked precedence example”Precedence is easier to trust once you have watched it resolve.
env: STAGE: workflow
jobs: demo: runs-on: ubuntu-latest env: STAGE: job steps: - run: echo "1: $STAGE" # job
- run: echo "2: $STAGE" # step env: STAGE: step
- run: echo "STAGE=file" >> "$GITHUB_ENV"
- run: echo "3: $STAGE" # file
- run: echo "4: $STAGE" # step — overrides the file env: STAGE: step-againOutput:
1: job2: step3: file4: step-againThe rule that emerges: a value written to $GITHUB_ENV behaves as if it were set at job level for
subsequent steps — it overrides the workflow level and is itself overridden by a step-level env:.
Variables in expressions versus in the shell
Section titled “Variables in expressions versus in the shell”The same value is reachable two ways, and they resolve at different times:
- run: echo "context: ${{ env.APP_NAME }}" # substituted by GitHub- run: echo "shell: $APP_NAME" # read by the shellBoth print the same thing for a trusted value. They differ in three ways that matter:
Timing. The context form is substituted before the shell runs; the shell form is resolved during execution. A variable set by an earlier command in the same step is visible to the shell form and not to the context form.
Portability. The context form is identical on every platform; the shell form needs
$VAR, $env:VAR or %VAR% depending on the shell.
Safety. The context form injects text into the command. For anything untrusted, only the shell
form — reading from env: — is safe.
The practical rule: use the shell form by default, and reach for the context form only where the value must be interpolated into something that is not a shell command, such as an action input.
A worked configuration layout
Section titled “A worked configuration layout”For a repository deploying to two environments, the layout that avoids conditionals entirely:
Organisation variables DOCKER_REGISTRY, DEFAULT_TIMEZONERepository variables SERVICE_NAME, HEALTHCHECK_PATHEnvironment: staging REGION=eu-west-1, REPLICAS=2, LOG_LEVEL=debugEnvironment: production REGION=eu-west-2, REPLICAS=12, LOG_LEVEL=infoEnvironment secrets per-environment credentials, gated by protection rulesWorkflow env build flags that never varyOne deployment job reads vars.REGION and gets the right value because it declared
environment: production. No if: chains, no duplicated job definitions, and adding a third
environment means adding variables rather than editing the workflow.
That separation — configuration in the platform, logic in the workflow — is what keeps a deployment pipeline maintainable as environments multiply.
A closing distinction
Section titled “A closing distinction”Three storage mechanisms, one question each.
env — does this value belong in the workflow file, where anyone reading it can see it? If yes, it is
env.
vars — does it differ by environment or repository, and would you rather change it without editing
the workflow? Then it is a variable.
secrets — would disclosure matter? Then it is a secret, and the next question is whether
OIDC could remove the need for it entirely.