GitHub Actions #2 Core Syntax: Contexts, Expressions, if Conditions, and Passing Values

5 min read

The workflow in the previous post only flowed from top to bottom. Real-world workflows skip steps based on conditions, use the results of earlier steps later on, and exchange order and values between jobs. This post covers the four pieces of syntax that make that possible: contexts, expressions, if conditions, and value passing. Once these four are second nature, no workflow in the rest of this series will stop you on syntax.

Contexts: objects that carry run information #

Wrap something in ${{ ... }} inside a workflow and it becomes an expression, and inside an expression you can access contexts that hold information about the run. Here they are, starting with the most used.

ContextWhat it holdsTypical references
githubEvent, repository, and branch infogithub.ref, github.event_name, github.sha
envEnvironment variablesenv.MY_VAR
stepsOutputs and results of earlier stepssteps.build.outputs.version
needsOutputs and results of earlier jobsneeds.test.result
secretsSecrets (next post)secrets.AWS_ROLE_ARN
runnerRunner inforunner.os, runner.temp

For example, github.event_name is the name of the event that woke this run, so a single workflow can tell whether it arrived via push or pull_request. There is no need to memorize the full context list — keep the instinct that “any information about the current run lives in some context” and look up the docs when needed. One debugging trick worth adding: printing ${{ toJSON(github) }} with run: echo is the fastest way to see the entire contents of a context.

if conditions: running steps and jobs selectively #

if attaches to both steps and jobs. Three canonical patterns cover most of what you need in practice.

.github/workflows/deploy.yml
jobs:
  deploy:
    # 1. Run the job only for pushes to the main branch
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh

      # 2. Run only when a previous step failed
      - run: ./rollback.sh
        if: failure()

      # 3. Always run, success or failure (cleanup)
      - run: ./cleanup.sh
        if: always()

Inside if, you can omit the ${{ }} and it is still interpreted as an expression. What matters is telling the four status functions apart. The default is success() (run only when everything before succeeded), so a step with nothing attached is skipped once something earlier fails. failure() runs only on failure, always() runs no matter what unless the run is cancelled, and cancelled() runs only on cancellation. Failure notifications and cleanup work are the classic uses of these functions.

Passing values between steps: GITHUB_OUTPUT #

To use a value computed in an earlier step (a version number, an image tag) in a later one, declare it as an output. Give the step an id and write name=value to the $GITHUB_OUTPUT file.

.github/workflows/build.yml
      - name: Compute version
        id: meta
        run: echo "version=1.4.$(date +%Y%m%d)" >> "$GITHUB_OUTPUT"

      - name: Build
        run: ./build.sh --version "${{ steps.meta.outputs.version }}"

The receiving address is steps.<id>.outputs.<name>. There is also $GITHUB_ENV, which passes values as environment variables (injected into the env of every following step), and the rule for choosing is simple: OUTPUT to hand a specific value to a specific step, ENV to use it as an environment variable everywhere afterwards. Outputs make the origin explicit, so they are the recommended default.

Order and values between jobs: needs and outputs #

Jobs are parallel by default, so when order matters you declare it with needs. To pass a value, lift a step output up through job-level outputs.

.github/workflows/build-deploy.yml
jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.meta.outputs.version }}
    steps:
      - id: meta
        run: echo "version=1.4.0" >> "$GITHUB_OUTPUT"

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying version ${{ needs.build.outputs.version }}"

needs: build creates both the ordering (deploy after build succeeds) and the data channel (needs.build.outputs). Jobs run on separate runners, so the one thing to keep in mind is that files do not carry over automatically. String values travel through outputs; files such as build outputs travel through the artifacts we cover in #4.

env: three layers of environment variables #

Environment variables can be declared at three levels — workflow, job, and step — and the innermost wins.

.github/workflows/env-demo.yml
env:
  APP_NAME: myapp          # entire workflow

jobs:
  test:
    env:
      STAGE: ci            # this whole job
    steps:
      - run: echo "$APP_NAME / $STAGE / $DEBUG"
        env:
          DEBUG: "true"    # this step only

For values used in the shell, declaring them in env like this and reading them as $NAME is the safe pattern. Interpolating ${{ }} directly into a run script looks convenient, but it is the entry point for the script injection we cover in #9, so start building the habit now: values that come from outside always pass through env and get read as shell variables.

Recap #

What we covered in this post:

  • Inside ${{ }} expressions, contexts like github, env, steps, and needs give access to run information. A toJSON() dump is the debugging shortcut
  • if attaches to both steps and jobs, and the default is success(). Use failure() for failure handling and always() for cleanup
  • Values between steps are written to $GITHUB_OUTPUT and read as steps.<id>.outputs.<name>
  • Between jobs, needs creates both the ordering and the value channel, and files do not carry over automatically
  • Environment variables have three layers — workflow, job, and step — and reading external values through env into shell variables is the habit that prevents injection

In the next post (#3 Secrets, Variables, and Environments), we cover how to feed secret values into a workflow safely: the split between secrets and vars, and the environment feature that builds an approval gate in front of deploys.

X