GitHub Actions #2 Core Syntax: Contexts, Expressions, if Conditions, and Passing Values
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.
| Context | What it holds | Typical references |
|---|---|---|
github | Event, repository, and branch info | github.ref, github.event_name, github.sha |
env | Environment variables | env.MY_VAR |
steps | Outputs and results of earlier steps | steps.build.outputs.version |
needs | Outputs and results of earlier jobs | needs.test.result |
secrets | Secrets (next post) | secrets.AWS_ROLE_ARN |
runner | Runner info | runner.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.
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.
- 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.
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.
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 onlyFor 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. AtoJSON()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_OUTPUTand read assteps.<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.