Module 4: Secrets and Environment Management
3. Environment Variables in Workflows
Overview
In the previous capsule you learned to use secrets — encrypted values that GitHub masks in the logs. But not everything you need in a workflow is sensitive. Your application's name, the environment (staging/production), the Python version, the cost threshold — all of this is configuration, not credentials. That's what environment variables are for.
The problem is that GitHub Actions has two mechanisms that look a lot alike: env (environment variables) and secrets. Both get passed as environment variables to the runner. Both are used with a similar syntax. But the differences are fundamental: env is visible in the logs, secrets gets masked. Confusing them is one of the most common sources of leaks.
This capsule teaches you to use environment variables correctly in workflows: the three levels of env (workflow, job, step), the precedence when there are conflicts, and the clear rule for when to use env vs secrets.
What you're going to learn:
- Defining environment variables at the workflow, job, and step level
- Understanding precedence when the same name exists at multiple levels
- Clearly distinguishing between
envandsecrets - Using variables for non-sensitive configuration
- Combining
envandsecretsin the same workflow
The three levels of env
Level 1: Workflow-level env
Variables available to every job and every step in the workflow:
name: AI Quality Gate
on: push
env:
PYTHON_VERSION: "3.12"
APP_NAME: "my-ai-app"
COST_THRESHOLD: "5.00"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Show config
run: |
echo "Python: $PYTHON_VERSION"
echo "App: $APP_NAME"
echo "Threshold: $COST_THRESHOLD"
# Output:
# Python: 3.12
# App: my-ai-app
# Threshold: 5.00
test:
runs-on: ubuntu-latest
steps:
- name: Show config
run: echo "Also sees $APP_NAME"
# Output: Also sees my-ai-app
Workflow-level env is for configuration shared across every job. Change it in one place and every job sees it.
Level 2: Job-level env
Variables available only to that job's steps:
name: Multi-Environment Pipeline
on: push
jobs:
staging:
runs-on: ubuntu-latest
env:
APP_ENV: "staging"
LOG_LEVEL: "debug"
steps:
- name: Deploy staging
run: echo "Deploying to $APP_ENV with log level $LOG_LEVEL"
# Output: Deploying to staging with log level debug
production:
runs-on: ubuntu-latest
env:
APP_ENV: "production"
LOG_LEVEL: "warning"
steps:
- name: Deploy production
run: echo "Deploying to $APP_ENV with log level $LOG_LEVEL"
# Output: Deploying to production with log level warning
Each job has its own set of variables. The staging job doesn't see production's variables and vice versa.
Level 3: Step-level env
Variables available only to that specific step:
jobs:
ai-checks:
runs-on: ubuntu-latest
steps:
- name: Run prompt evaluation
run: python scripts/evaluate_prompts.py
env:
EVAL_MODE: "strict"
MAX_RETRIES: "3"
- name: Run cost estimation
run: python scripts/estimate_cost.py
env:
COST_MODEL: "gpt-4o-mini"
DAILY_REQUESTS: "10000"
- name: Check variables from previous steps
run: |
echo "EVAL_MODE: ${EVAL_MODE:-not set}"
echo "COST_MODEL: ${COST_MODEL:-not set}"
# Output:
# EVAL_MODE: not set
# COST_MODEL: not set
Step-level env is the most restrictive — the variable exists only during that step's execution. The third step can't see the variables of the first or the second.
Precedence: what happens when there are conflicts
If you define a variable with the same name at multiple levels, the most specific level wins:
name: Precedence Demo
on: workflow_dispatch
env:
MY_VAR: "workflow-level"
jobs:
demo:
runs-on: ubuntu-latest
env:
MY_VAR: "job-level"
steps:
- name: Step with own override
run: echo "MY_VAR = $MY_VAR"
env:
MY_VAR: "step-level"
# Output: MY_VAR = step-level
- name: Step without override
run: echo "MY_VAR = $MY_VAR"
# Output: MY_VAR = job-level
The precedence rule
Step-level env → Highest priority
↓
Job-level env
↓
Workflow-level env → Lowest priority
The first step sees step-level because it defines its own MY_VAR. The second step sees job-level because it has no step-level override, and the job-level overrides the workflow-level.
The complete precedence table
| Level | Scope | Priority |
|---|---|---|
Step env | That step only | Highest |
Job env | Every step in the job | Medium |
Workflow env | Every job | Low |
vars (repo variables) | Every workflow in the repo | Lowest |
env vs secrets: the fundamental difference
A direct comparison
| Aspect | env | secrets |
|---|---|---|
| Visibility in logs | Visible in plain text | Masked with *** |
| Where it's defined | In the workflow's YAML | In GitHub Settings |
| Encryption | No | Yes (libsodium sealed box) |
| Editable by others | Anyone who edits the workflow | Only with access to Settings |
| What to use it for | Non-sensitive configuration | Credentials, API keys, tokens |
A clear example
env:
# ✅ Correct: non-sensitive configuration in env
APP_ENV: "staging"
PYTHON_VERSION: "3.12"
COST_THRESHOLD: "5.00"
LOG_LEVEL: "debug"
MAX_RETRIES: "3"
jobs:
ai-checks:
runs-on: ubuntu-latest
steps:
- name: Run AI evaluation
run: python scripts/evaluate_prompts.py
env:
# ✅ Correct: credentials as secrets
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
# ✅ Correct: configuration as env (inherited from the workflow level)
# APP_ENV, COST_THRESHOLD, etc. are already available
The simple rule
Is the value something you could share publicly?
YES → use env
NO → use secrets
Examples:
"production" → env (it's not sensitive)
"sk-proj-abc123..." → secret (it's a credential)
"5.00" → env (it's a threshold)
"ghp_xxxx..." → secret (it's a token)
"3.12" → env (it's a version)
"password123" → secret (it's a password)
GitHub Variables (vars): the UI alternative
Besides defining env in the YAML, you can create variables in GitHub's UI:
Settings → Secrets and variables → Actions → Variables tab → New repository variable
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Use repo variable
run: echo "Environment: ${{ vars.DEPLOY_ENV }}"
Differences between env (YAML) and vars (UI)
| Aspect | env in YAML | vars in the UI |
|---|---|---|
| Where it's defined | In the workflow's YAML file | In GitHub Settings |
| Who can change it | Anyone who edits the file | Only with access to Settings |
| Requires a commit | Yes (changing the YAML is a commit) | No (immediate change) |
| Syntax | $MY_VAR or ${{ env.MY_VAR }} | ${{ vars.MY_VAR }} |
vars is useful for values you want to change without making a commit. If the cost threshold changes frequently, define it as vars.COST_THRESHOLD in the UI and you avoid a commit every time.
Common patterns
Pattern 1: Centralized configuration
name: AI Pipeline
on: push
env:
PYTHON_VERSION: "3.12"
MODEL: "gpt-4o-mini"
COST_THRESHOLD: "5.00"
DAILY_REQUESTS: "10000"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- run: ruff check src/
cost-check:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- run: |
python scripts/estimate_cost.py \
--model "$MODEL" \
--threshold "$COST_THRESHOLD" \
--daily-requests "$DAILY_REQUESTS"
All the configuration lives in an env block at the top of the file. Any change is made in a single place.
Pattern 2: Dynamic variables between steps
jobs:
compute:
runs-on: ubuntu-latest
steps:
- name: Calculate values
id: calc
run: |
TOKENS=$(python -c "import tiktoken; enc = tiktoken.encoding_for_model('gpt-4o-mini'); print(len(enc.encode('Hello world')))")
echo "token_count=$TOKENS" >> $GITHUB_OUTPUT
echo "Tokens calculated: $TOKENS"
- name: Use calculated values
run: echo "Token count from previous step: ${{ steps.calc.outputs.token_count }}"
$GITHUB_OUTPUT lets you pass values from one step to another. This isn't env — it's GitHub Actions' outputs mechanism.
Pattern 3: Defaults with an override
env:
EVAL_THRESHOLD: "0.10"
jobs:
ai-checks:
runs-on: ubuntu-latest
steps:
- name: Standard evaluation
run: python scripts/evaluate.py --threshold "$EVAL_THRESHOLD"
# Uses 0.10 (from the workflow env)
- name: Strict evaluation
run: python scripts/evaluate.py --threshold "$EVAL_THRESHOLD"
env:
EVAL_THRESHOLD: "0.05"
# Uses 0.05 (the step's override)
The workflow level defines the default. Specific steps can override it when they need a different value.
Pattern 4: Combining env and secrets
env:
APP_ENV: "staging"
MODEL: "gpt-4o-mini"
jobs:
ai-evaluation:
runs-on: ubuntu-latest
steps:
- name: Run evaluation
run: |
echo "Environment: $APP_ENV"
echo "Model: $MODEL"
echo "API Key set: $([ -n "$OPENAI_API_KEY" ] && echo 'yes' || echo 'no')"
python scripts/evaluate_prompts.py
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
Output in the logs:
Environment: staging
Model: gpt-4o-mini
API Key set: yes
APP_ENV and MODEL are visible. OPENAI_API_KEY is never printed directly — we only verify that it exists.
GitHub Actions' special variables
GitHub automatically injects variables with information about the workflow:
- name: Show GitHub context
run: |
echo "Repository: $GITHUB_REPOSITORY"
echo "Branch: $GITHUB_REF_NAME"
echo "Commit SHA: $GITHUB_SHA"
echo "Actor: $GITHUB_ACTOR"
echo "Event: $GITHUB_EVENT_NAME"
echo "Run ID: $GITHUB_RUN_ID"
echo "Run number: $GITHUB_RUN_NUMBER"
echo "Workspace: $GITHUB_WORKSPACE"
Output:
Repository: your-username/your-repo
Branch: main
Commit SHA: abc123def456...
Actor: your-username
Event: push
Run ID: 12345678
Run number: 42
Workspace: /home/runner/work/your-repo/your-repo
These variables are available without declaring them in env. They're read-only.
Variables useful for AI pipelines
| Variable | Use in AI pipelines |
|---|---|
GITHUB_SHA | Tag evaluation reports with the commit |
GITHUB_REF_NAME | Determine whether to use staging or production secrets |
GITHUB_RUN_ID | Identify the run in evaluation logs |
GITHUB_EVENT_NAME | Decide whether to run a full evaluation (PR) or a fast one (push) |
Anti-patterns: what you should NOT do
Anti-pattern 1: Secrets in the workflow's env
# ❌ NEVER do this
env:
OPENAI_API_KEY: "sk-proj-abc123..."
# ✅ Correct
jobs:
ai-checks:
steps:
- run: python scripts/evaluate.py
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
If you put a sensitive value in env at the workflow level, it ends up in the YAML — which is code committed to the repo.
Anti-pattern 2: Configuration in secrets
# ❌ Don't use secrets for non-sensitive configuration
steps:
- run: echo "Version: ${{ secrets.PYTHON_VERSION }}"
# ✅ Use env or vars for configuration
env:
PYTHON_VERSION: "3.12"
Don't waste secret slots (a maximum of 100 per repo) on non-sensitive values. Besides, you can't see a secret's value — which complicates debugging your configuration.
Anti-pattern 3: Variables hardcoded in multiple places
# ❌ The same value repeated in several steps
steps:
- run: python scripts/evaluate.py --model gpt-4o-mini
- run: python scripts/cost.py --model gpt-4o-mini
- run: python scripts/report.py --model gpt-4o-mini
# ✅ Centralize it in env
env:
MODEL: "gpt-4o-mini"
steps:
- run: python scripts/evaluate.py --model "$MODEL"
- run: python scripts/cost.py --model "$MODEL"
- run: python scripts/report.py --model "$MODEL"
If you need to change the model, you do it in a single place.
Troubleshooting
"The env variable doesn't expand in a with block"
Cause: In with blocks (an action's inputs), the $MY_VAR syntax doesn't work. You need ${{ env.MY_VAR }}.
Solution:
env:
PYTHON_VERSION: "3.12"
steps:
# ❌ Doesn't work
- uses: actions/setup-python@v5
with:
python-version: $PYTHON_VERSION
# ✅ Works
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
"The variable has an unexpected value in a step"
Cause: A more specific level is overriding the value.
Solution: Check all three levels (workflow, job, step) to spot conflicts:
- name: Debug variable sources
run: |
echo "MY_VAR value: $MY_VAR"
echo "Check workflow-level, job-level, and step-level env blocks"
"I want to pass a step's result as env to another step"
Cause: A step's environment variables don't persist to the next one.
Solution: Use $GITHUB_OUTPUT:
- name: Calculate
id: calc
run: echo "result=42" >> $GITHUB_OUTPUT
- name: Use result
run: echo "Result: ${{ steps.calc.outputs.result }}"
Or use $GITHUB_ENV to create a variable that persists across the job:
- name: Set persistent env
run: echo "MY_RESULT=42" >> $GITHUB_ENV
- name: Use persistent env
run: echo "Result: $MY_RESULT"
# Output: Result: 42
"The quotes get lost in the env value"
Cause: YAML and the shell handle quotes differently.
Solution: Use double quotes in the YAML:
env:
# ❌ It can cause problems
MESSAGE: Hello World
# ✅ Safe
MESSAGE: "Hello World"
# ✅ Also safe for JSON
CONFIG: '{"model": "gpt-4o-mini", "max_tokens": 500}'
Exercises
Exercise 1: A workflow with centralized configuration
Create a workflow that centralizes all the configuration in the workflow's env block and uses it in three different jobs: lint, test, and cost-check.
See solution
# .github/workflows/centralized-config.yml
name: Centralized Config
on: workflow_dispatch
env:
PYTHON_VERSION: "3.12"
MODEL: "gpt-4o-mini"
COST_THRESHOLD: "5.00"
APP_NAME: "ai-quality-gate"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- run: pip install ruff
- run: |
echo "Linting $APP_NAME..."
ruff check src/ || true
echo "Model configured: $MODEL"
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- run: pip install -r requirements-dev.txt
- run: |
echo "Testing $APP_NAME..."
pytest tests/ -v --tb=short || true
cost-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- run: pip install -r requirements.txt
- run: |
echo "Checking costs for $APP_NAME"
echo "Model: $MODEL"
echo "Threshold: $COST_THRESHOLD"
python scripts/estimate_cost.py \
--threshold "$COST_THRESHOLD" || true
Key points:
PYTHON_VERSIONgets used in all three jobs with${{ env.PYTHON_VERSION }}MODEL,COST_THRESHOLD, andAPP_NAMEare available in every step as$VARIABLE- Changing the Python version or the model is done in a single place
Exercise 2: Demonstrate precedence
Create a workflow that defines APP_ENV at all three levels (workflow, job, step) and demonstrates which value wins in each step.
See solution
# .github/workflows/precedence-demo.yml
name: Precedence Demo
on: workflow_dispatch
env:
APP_ENV: "workflow-level"
jobs:
demo:
runs-on: ubuntu-latest
env:
APP_ENV: "job-level"
steps:
- name: Step with own env
run: echo "APP_ENV = $APP_ENV"
env:
APP_ENV: "step-level"
# Output: APP_ENV = step-level
- name: Step without own env
run: echo "APP_ENV = $APP_ENV"
# Output: APP_ENV = job-level
other-job:
runs-on: ubuntu-latest
steps:
- name: Step in different job
run: echo "APP_ENV = $APP_ENV"
# Output: APP_ENV = workflow-level
Expected output:
# Job: demo, Step 1
APP_ENV = step-level
# Job: demo, Step 2
APP_ENV = job-level
# Job: other-job, Step 1
APP_ENV = workflow-level
Every step sees the value of the most specific level that applies to it.
Exercise 3: Dynamic variables with GITHUB_OUTPUT
Create a workflow where one step calculates the current date and the next step uses it to name a report file.
See solution
# .github/workflows/dynamic-vars.yml
name: Dynamic Variables
on: workflow_dispatch
jobs:
generate-report:
runs-on: ubuntu-latest
steps:
- name: Calculate report metadata
id: metadata
run: |
DATE=$(date +%Y-%m-%d)
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
echo "date=$DATE" >> $GITHUB_OUTPUT
echo "timestamp=$TIMESTAMP" >> $GITHUB_OUTPUT
echo "report_name=eval-report-$TIMESTAMP.json" >> $GITHUB_OUTPUT
echo "Metadata calculated: date=$DATE, timestamp=$TIMESTAMP"
- name: Generate report
run: |
REPORT_NAME="${{ steps.metadata.outputs.report_name }}"
echo "Generating report: $REPORT_NAME"
echo '{"date": "${{ steps.metadata.outputs.date }}", "status": "ok"}' > "$REPORT_NAME"
cat "$REPORT_NAME"
- name: Confirm
run: |
echo "Report date: ${{ steps.metadata.outputs.date }}"
echo "Report file: ${{ steps.metadata.outputs.report_name }}"
ls -la eval-report-*.json
Expected output:
# Step 1
Metadata calculated: date=2026-03-08, timestamp=20260308-143022
# Step 2
Generating report: eval-report-20260308-143022.json
{"date": "2026-03-08", "status": "ok"}
# Step 3
Report date: 2026-03-08
Report file: eval-report-20260308-143022.json
-rw-r--r-- 1 runner runner 42 Mar 8 14:30 eval-report-20260308-143022.json
Exercise 4: Conditional configuration per branch
Create a workflow that uses different variables depending on whether the push is to main or to another branch. On main use production as the environment; on any other branch, use development.
See solution
# .github/workflows/conditional-env.yml
name: Conditional Environment
on:
push:
branches: ["*"]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Set environment based on branch
id: set-env
run: |
if [ "$GITHUB_REF_NAME" = "main" ]; then
echo "app_env=production" >> $GITHUB_OUTPUT
echo "log_level=warning" >> $GITHUB_OUTPUT
echo "cost_threshold=5.00" >> $GITHUB_OUTPUT
else
echo "app_env=development" >> $GITHUB_OUTPUT
echo "log_level=debug" >> $GITHUB_OUTPUT
echo "cost_threshold=10.00" >> $GITHUB_OUTPUT
fi
echo "Branch: $GITHUB_REF_NAME"
- name: Show configuration
run: |
echo "Environment: ${{ steps.set-env.outputs.app_env }}"
echo "Log level: ${{ steps.set-env.outputs.log_level }}"
echo "Cost threshold: ${{ steps.set-env.outputs.cost_threshold }}"
env:
APP_ENV: ${{ steps.set-env.outputs.app_env }}
- name: Run with configuration
run: |
echo "Running in $APP_ENV mode"
env:
APP_ENV: ${{ steps.set-env.outputs.app_env }}
Expected output (push to main):
Branch: main
Environment: production
Log level: warning
Cost threshold: 5.00
Running in production mode
Expected output (push to feature/xyz):
Branch: feature/xyz
Environment: development
Log level: debug
Cost threshold: 10.00
Running in development mode
Summary
- ✅ Three levels of
env: workflow (global), job (per job), step (per step) - ✅ Precedence: step > job > workflow — the most specific level wins
- ✅
envvssecrets: env is visible in the logs (configuration), secrets get masked (credentials) - ✅ GitHub Variables (
vars): the UI alternative for configuration that changes without commits - ✅ GitHub's variables:
GITHUB_SHA,GITHUB_REF_NAME,GITHUB_ACTOR, etc. are available automatically - ✅
GITHUB_OUTPUT: the mechanism for passing values between steps - ✅
GITHUB_ENV: the mechanism for creating persistent variables within a job - ✅ The simple rule: if you could share it publicly →
env; if not →secrets - ✅ Centralizing configuration in the workflow's
envblock reduces duplication and errors
Additional resources
- GitHub Actions — Environment Variables — Official documentation on variables
- GitHub Actions — Workflow Commands — GITHUB_OUTPUT, GITHUB_ENV, etc.
- GitHub Actions — Contexts — Contexts: env, secrets, github, steps
- GitHub Actions — Default Environment Variables — The runner's automatic variables
- YAML Syntax for Workflows — Reference for the
envkey in YAML - GitHub Variables (UI) — Configuration variables in the UI