GitHub Actions Workflow Syntax
The YAML that drives a workflow. Events and filters, job dependencies, conditionals, contexts and expressions, explained with the failure each one prevents.
A workflow file has a small number of top-level keys, and most confusing behaviour comes from three of them: on, needs and if. This lesson covers the syntax you will actually reach for, and for each piece explains the specific failure it prevents.
Verified against GitHub Actions as of September 2026.
The Skeleton
name: CI
run-name: CI for ${{ github.ref_name }}
on:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
NODE_ENV: test
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
timeout-minutes deserves attention. The default job timeout is 360 minutes, which means a hung job burns six hours of your allowance before anyone notices. Setting a realistic ceiling on every job is one of the highest-value habits in this file.
Events and Filtering
The on: block decides when a workflow runs. The common events:
on:
push:
branches: [main, 'release/**']
paths:
- 'src/**'
- 'package-lock.json'
pull_request:
types: [opened, synchronize, reopened]
schedule:
- cron: '0 3 * * 1'
workflow_dispatch:
inputs:
environment:
description: Target environment
required: true
type: choice
options: [staging, production]
Two filters are worth knowing precisely.
paths runs the workflow only when matching files changed. This is how you avoid running a full test suite because someone fixed a typo in the README. The inverse, paths-ignore, is also available, but you cannot use both in the same event block.
branches accepts glob patterns. 'release/**' matches any depth under release/, while 'release/*' matches only one segment. Quote any pattern containing *, because unquoted YAML treats some of these as aliases and will fail to parse.
A caution about paths and required status checks: if a workflow is a required check for merging and paths prevents it from running, the pull request can wait forever for a check that will never report. The usual answer is a second lightweight workflow with the same job name that runs unconditionally and passes immediately.
The schedule event uses UTC, always, regardless of repository or account settings. A 0 3 * * 1 schedule is 3am UTC on Monday, not 3am wherever you are. Scheduled runs also only fire from the default branch, and GitHub disables them on repositories with no activity for 60 days.
For building cron expressions without guessing, the cron expression parser shows the next run times for a given schedule.
Job Dependencies
Jobs are parallel by default. needs: creates the ordering.
jobs:
lint:
runs-on: ubuntu-latest
steps: [{ uses: actions/checkout@v4 }, { run: npm run lint }]
test:
runs-on: ubuntu-latest
steps: [{ uses: actions/checkout@v4 }, { run: npm test }]
deploy:
needs: [lint, test]
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
lint and test start at the same time. deploy waits for both, and is skipped entirely if either fails.
The important and frequently missed point: each job gets a different runner, so nothing produced by lint exists in test. No files, no installed packages, no environment variables set with export. Passing data between jobs requires either artifacts for files or job outputs for values.
Job outputs look like this:
jobs:
version:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.compute.outputs.tag }}
steps:
- id: compute
run: echo "tag=v1.2.3" >> "$GITHUB_OUTPUT"
release:
needs: version
runs-on: ubuntu-latest
steps:
- run: echo "Releasing ${{ needs.version.outputs.tag }}"
Note >> "$GITHUB_OUTPUT" rather than the old ::set-output command, which was disabled in 2023. Any tutorial still showing ::set-output predates that change and its examples will not work.
Conditionals
if: runs on a job or a step. It is evaluated as an expression, so the ${{ }} wrapper is optional here and usually omitted.
- name: Deploy
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: ./deploy.sh
- name: Notify on failure
if: failure()
run: ./notify.sh
The status functions are the part people miss. A step is skipped by default when an earlier step failed, so a cleanup or notification step needs if: always() or if: failure() to run at all. The four available are success(), failure(), cancelled() and always().
A subtlety worth internalising: if: always() also runs when the job was cancelled, which can keep a cancelled workflow alive. When you want "run on pass or fail, but respect cancellation", use if: !cancelled().
Contexts and Expressions
Contexts are the objects available inside ${{ }}. The ones you will use constantly:
github carries event data. github.ref is the full ref such as refs/heads/main, github.ref_name is just main, github.sha is the commit, github.event_name is what triggered the run, and github.repository is owner/name.
env, vars and secrets carry configuration, covered in the secrets and variables lesson.
runner describes the machine. runner.os is Linux, Windows or macOS, and runner.temp is a scratch directory guaranteed to be writable.
matrix carries the current combination in a matrix build.
One trap with expressions: github.event shape depends entirely on the triggering event. github.event.pull_request.number exists on a pull_request run and is empty on a push run, so a workflow responding to both events cannot assume either.
Shell Behaviour
On Linux and macOS runners, run steps execute with bash -e, so the step fails at the first failing command. It does not use -o pipefail by default, which means a failure in the middle of a pipe is invisible:
# Passes even when the build fails, because tee succeeds.
- run: npm run build | tee build.log
# Fails correctly.
- run: set -o pipefail && npm run build | tee build.log
You can set this once for the whole workflow:
defaults:
run:
shell: bash
which uses bash --noprofile --norc -eo pipefail, giving you pipefail everywhere without repeating it.
Frequently Asked Questions
Why is my workflow not running at all?
In order of likelihood: the file is not in .github/workflows/, the YAML is invalid so GitHub cannot parse the on: block, the branch filter does not match the branch you pushed, a paths filter excluded your changes, or the workflow is on a branch other than the default and you are expecting a schedule trigger. Invalid YAML is the quietest of these, because a workflow that cannot be parsed simply never appears.
Can I use environment variables inside the on: block?
No. The on:, runs-on and needs keys are evaluated before any context beyond github and inputs exists, so env values and secrets are not available there. This restriction catches people trying to make a schedule or a runner label configurable.
What is the difference between env, vars and secrets?
env values are defined in the workflow file and are plain text. vars are non-sensitive values configured in repository or organisation settings. secrets are encrypted values that are masked in logs. Use vars for things like a region name and secrets for anything that grants access.
How do I run a step only on the default branch?
if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) is the robust form. Hardcoding refs/heads/main works until someone renames the branch.
Does needs make jobs share a filesystem?
No. needs controls order only. Each job runs on a clean runner, so files must move through artifacts and values through job outputs.