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:

  1. Defining environment variables at the workflow, job, and step level
  2. Understanding precedence when the same name exists at multiple levels
  3. Clearly distinguishing between env and secrets
  4. Using variables for non-sensitive configuration
  5. Combining env and secrets in 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

LevelScopePriority
Step envThat step onlyHighest
Job envEvery step in the jobMedium
Workflow envEvery jobLow
vars (repo variables)Every workflow in the repoLowest

env vs secrets: the fundamental difference

A direct comparison

Aspectenvsecrets
Visibility in logsVisible in plain textMasked with ***
Where it's definedIn the workflow's YAMLIn GitHub Settings
EncryptionNoYes (libsodium sealed box)
Editable by othersAnyone who edits the workflowOnly with access to Settings
What to use it forNon-sensitive configurationCredentials, 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)

Aspectenv in YAMLvars in the UI
Where it's definedIn the workflow's YAML fileIn GitHub Settings
Who can change itAnyone who edits the fileOnly with access to Settings
Requires a commitYes (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

VariableUse in AI pipelines
GITHUB_SHATag evaluation reports with the commit
GITHUB_REF_NAMEDetermine whether to use staging or production secrets
GITHUB_RUN_IDIdentify the run in evaluation logs
GITHUB_EVENT_NAMEDecide 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_VERSION gets used in all three jobs with ${{ env.PYTHON_VERSION }}
  • MODEL, COST_THRESHOLD, and APP_NAME are 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
  • env vs secrets: 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 env block reduces duplication and errors

Additional resources

  1. GitHub Actions — Environment Variables — Official documentation on variables
  2. GitHub Actions — Workflow Commands — GITHUB_OUTPUT, GITHUB_ENV, etc.
  3. GitHub Actions — Contexts — Contexts: env, secrets, github, steps
  4. GitHub Actions — Default Environment Variables — The runner's automatic variables
  5. YAML Syntax for Workflows — Reference for the env key in YAML
  6. GitHub Variables (UI) — Configuration variables in the UI