Video Thumbnail for Lesson
3.3: Variables and Data Flow

Environment Variables and Scope

The 03-core-features--05-environment-variables.yaml workflow clarifies how environment variables cascade:

env:
  WORKFLOW_VAR: I_AM_WORKFLOW_SCOPED

jobs:
  job-1:
    runs-on: ubuntu-24.04
    env:
      JOB_VAR: I_AM_JOB_1_SCOPED
    steps:
      - name: Inspect scopes job 1 step 1
        env:
          STEP_VAR: I_AM_STEP_SCOPED
        run: |
          echo "WORKFLOW_VAR: $WORKFLOW_VAR"   # visible
          echo "JOB_VAR:      $JOB_VAR"        # visible
          echo "STEP_VAR:     $STEP_VAR"       # visible only here

      - name: Inspect scopes job 1 step 2
        run: |
          echo "WORKFLOW_VAR: $WORKFLOW_VAR"         # visible
          echo "JOB_VAR:      $JOB_VAR"              # visible
          echo "STEP_VAR:     ${STEP_VAR:-<UNSET>}"  # not set here

  job-2:
    runs-on: ubuntu-24.04
    steps:
      - name: Inspect scopes job 2 step 2
        run: |
          echo "WORKFLOW_VAR: $WORKFLOW_VAR"                 # still visible
          echo "JOB_VAR:      ${FOO:-<UNSET>}"               # not set here
          echo "STEP_VAR:     ${STEP_VAR:-<UNSET>}"          # not set here

Remember:

  • Variables defined at the workflow level are available everywhere.
  • Variables defined on a job only exist inside that job.
  • Step-level variables override job and workflow values but vanish once the step completes.
  • Use ${{ env.VAR_NAME }} when you need to reference a variable from YAML configuration (for example inside an if: condition).

Passing data between steps and jobs

Sometimes you need to pass dynamic values forward such as build numbers, artifact paths, checksums, etc. GitHub Actions provides two special files for this:

  • Append KEY=value lines to $GITHUB_ENV to create environment variables that persist for the rest of the job.
  • Append KEY=value lines to $GITHUB_OUTPUT inside a step that has an id. You can then expose those as job outputs and consume them from downstream jobs with the needs context.

03-core-features--06-passing-data.yaml combines both techniques:

jobs:
  producer:
    runs-on: ubuntu-24.04

    outputs:
      foo: ${{ steps.generate-foo.outputs.foo }}
    steps:
      - name: Generate and export foo
        id: generate-foo
        run: |
          foo=bar

          # 1) Step output (for job output)
          echo "foo=${foo}" >> "$GITHUB_OUTPUT"

          # 2) Job-scoped environment variable
          echo "FOO=${foo}" >> "$GITHUB_ENV"

      - name: Inspect values inside producer
        run: |
          echo "foo (step output):          ${{ steps.generate-foo.outputs.foo }}"
          echo "FOO (set via GITHUB_ENV):   $FOO"

  consumer:
    runs-on: ubuntu-24.04
    needs: producer
    steps:
      - name: Inspect values inside consumer (note FOO is unset)
        run: |
          echo "Value from producer:        ${{ needs.producer.outputs.foo }}"
          echo "FOO in consumer:            ${FOO:-<UNSET>}"

Notice how FOO (set via $GITHUB_ENV) disappears in the downstream job while the job output remains accessible.