Contexts Explained
Every step in a workflow runs in its own shell process, and every one of those processes has environment variables available to it — some GitHub sets automatically, some you set yourself, and where you set them determines how far they reach. Getting the scoping right is the difference between a variable being available exactly where you need it and a workflow where half your steps mysteriously can’t see a value the other half can.
Default Environment Variables
GitHub injects a set of variables into every job automatically, without you declaring anything. A few of the most useful:
name: Default Env Demo
on: workflow_dispatch
jobs:
show-defaults:
runs-on: ubuntu-latest
steps:
- run: |
echo "Repository: $GITHUB_REPOSITORY"
echo "Ref: $GITHUB_REF"
echo "SHA: $GITHUB_SHA"
echo "Actor: $GITHUB_ACTOR"
echo "Workspace: $GITHUB_WORKSPACE"
echo "Run ID: $GITHUB_RUN_ID"Try it:
mkdir gha-env-demo && cd gha-env-demo
git init
mkdir -p .github/workflows
cat > .github/workflows/default-env.yml << 'EOF'
name: Default Env Demo
on: workflow_dispatch
jobs:
show-defaults:
runs-on: ubuntu-latest
steps:
- run: |
echo "Repository: $GITHUB_REPOSITORY"
echo "Ref: $GITHUB_REF"
echo "SHA: $GITHUB_SHA"
echo "Actor: $GITHUB_ACTOR"
echo "Workspace: $GITHUB_WORKSPACE"
echo "Run ID: $GITHUB_RUN_ID"
EOF
git add .
git commit -m "Add default env demo workflow"
git branch -M mainPush and trigger it manually — the log will show real values pulled straight from GitHub’s own metadata about the run, with no configuration on your part.
Note
These are conventionally referenced two ways: as shell environment variables ($GITHUB_SHA, all-caps with underscores) inside a run: step, or through the github context (${{ github.sha }}, lowercase, dotted) inside expressions. Both point at the same underlying data — the context version gets its own full treatment shortly.
Custom Environment Variables: Workflow, Job, and Step Level
Beyond the defaults, you can define your own env block at three different levels, and they nest — a variable set at a broader scope is visible to everything narrower, and a narrower scope can override it.
name: Env Scoping Demo
on: workflow_dispatch
env:
APP_NAME: devstuffs-demo
jobs:
scoped:
runs-on: ubuntu-latest
env:
STAGE: build
steps:
- name: Workflow and job level
run: echo "App is $APP_NAME, stage is $STAGE"
- name: Step level override
env:
STAGE: test
run: echo "App is $APP_NAME, stage is now $STAGE"Add this and run it:
cat > .github/workflows/env-scoping.yml << 'EOF'
name: Env Scoping Demo
on: workflow_dispatch
env:
APP_NAME: devstuffs-demo
jobs:
scoped:
runs-on: ubuntu-latest
env:
STAGE: build
steps:
- name: Workflow and job level
run: echo "App is $APP_NAME, stage is $STAGE"
- name: Step level override
env:
STAGE: test
run: echo "App is $APP_NAME, stage is now $STAGE"
EOF
git add .
git commit -m "Add env scoping demo"Push and trigger it — the first step prints stage is build, the second prints stage is now test, while APP_NAME stays devstuffs-demo in both, since nothing overrides it. This is exactly how you’d expect variable shadowing to work in most programming languages: the closest, most specific definition wins.
Scope Summary
| Level | Defined Under | Visible To |
|---|---|---|
| Workflow | Top-level env: | Every job and step in the file |
| Job | jobs.<id>.env: | Every step in that job only |
| Step | jobs.<id>.steps[].env: | That single step only |
A Common Trap: env Doesn’t Cross Jobs Automatically
Each job in a workflow runs on a fresh runner (unless configured otherwise), so a env variable set in one job is invisible to a different job — even one declared right below it in the same file.
jobs:
first:
runs-on: ubuntu-latest
env:
TOKEN: abc123
steps:
- run: echo "Visible here: $TOKEN"
second:
runs-on: ubuntu-latest
steps:
- run: echo "Visible here too? $TOKEN" # prints emptyThe second job’s $TOKEN prints nothing — job-level env doesn’t leak sideways into other jobs. If you genuinely need to pass a value from one job to another, that’s a job-outputs problem, not an env problem, and it gets its own dedicated chapter.
Dynamically Setting Variables Mid-Job
A step can also set an environment variable for the rest of the job (not just itself) by writing to the special $GITHUB_ENV file.
steps:
- name: Compute a value
run: echo "BUILD_TIME=$(date -u +%Y%m%dT%H%M%S)" >> "$GITHUB_ENV"
- name: Use it later
run: echo "This build happened at $BUILD_TIME"This is how you compute something at runtime — a timestamp, a git short SHA, a version string parsed from a file — and make it available to every subsequent step in the same job, without hardcoding it in the YAML.
How Real DevOps Engineers Use This
Workflow-level env is where teams put things that genuinely apply everywhere — a Python version string, an app name used in log messages, a default AWS region. Job-level env handles per-job configuration, like a STAGE=build versus STAGE=deploy distinction across different jobs in the same pipeline. Step-level env is reserved for the rare case where one specific command needs a variable that would be actively wrong anywhere else — for instance, temporarily setting AWS_PROFILE for a single AWS CLI call without letting it leak into unrelated steps.
The $GITHUB_ENV pattern is extremely common in real deploy pipelines for computing a Docker image tag or version string once, early in the job, and referencing it consistently through every later step — avoiding the situation where three different steps each recompute a timestamp and end up with three slightly different values.
Clean up:
cd .. && rm -rf gha-env-demoTip
If a variable “isn’t showing up,” check scope first. It’s almost always either defined in a different job, or defined at step level in a step that already finished.