GitHub Actions Workflow Syntax Explained
The GitHub Actions workflow file line by line: name, on triggers, jobs and runs-on, steps, env scopes, expressions, if conditionals, needs and job outputs.
Once you have the fundamentals - what a workflow is, where the file lives, when it runs - the next wall you hit is the YAML itself. A workflow file is small, but every key does something specific, and the failures are almost always a misunderstanding of one of them: a trigger that does not fire, a step that runs when it should not, a job that starts before the one it depends on finished. This guide walks the workflow file top to bottom, one key at a time, with a small correct example for each. By the end the whole file reads as a set of deliberate choices instead of copied boilerplate.
Every workflow is a single YAML file in .github/workflows/ in your repository. The top-level shape is always the same: an optional name, a required on (what triggers it), and a required jobs (what to run). Everything else hangs off those.
name: what the workflow is called
name is the label GitHub shows in the Actions tab and in commit status checks. It is optional - leave it off and GitHub uses the file path - but a clear name is worth the one line, because that string is what you scan when a run fails.
name: CI
Do not overthink it. One short line naming the workflow's job (CI, Deploy to prod, Nightly cleanup) is all you want. There is also a run-name key if you want the individual run title to be dynamic (for example including the actor), but name is the constant label for the workflow as a whole.
on: the triggers
on is the heart of the file - it decides when the workflow runs. This is where most "why did nothing happen?" and "why did this run twice?" confusion lives, so it pays to be precise. on can be a single event, a list of events, or a map of events with filters.
The simplest form is a list:
on: [push, pull_request]
That runs the workflow on every push and every pull request, with no filtering. Most real workflows want the map form so they can narrow it down.
push and pull_request with branch and path filters
The map form lets each event carry filters. For push and pull_request the useful ones are branches (which branches trigger it) and paths (which changed files trigger it). Filters are how you stop a workflow from running on every commit to every branch.
on:
push:
branches:
- main
- "release/**"
paths:
- "src/**"
- "package.json"
pull_request:
branches:
- main
That workflow runs on pushes to main or any release/* branch, but only when a file under src/ or package.json changed - and on pull requests targeting main. The ** is a glob that matches across path segments. There are -ignore variants too (branches-ignore, paths-ignore) for the inverse, but you cannot use branches and branches-ignore together on the same event - pick one direction.
One thing to internalize: for pull_request, the branches filter matches the target branch of the PR (where it is merging to), not the source branch. That trips people up constantly.
schedule: cron
schedule runs a workflow on a timer using standard cron syntax. There is no triggering commit - it just fires. Use it for nightly builds, cleanup jobs, or dependency checks.
on:
schedule:
- cron: "0 6 * * 1-5"
The five fields are minute, hour, day-of-month, month, day-of-week. The example above means 06:00 UTC, Monday through Friday. Two things to know: scheduled times are always UTC, and GitHub does not guarantee exact timing - runs can be delayed by several minutes under load, so do not build anything that needs second-level precision. Scheduled workflows only run from the default branch.
workflow_dispatch: manual runs with inputs
workflow_dispatch adds a "Run workflow" button in the Actions UI so you can trigger a run by hand. On its own it takes no configuration, but it can declare inputs that become a form when someone runs it - useful for deploys where you choose an environment or a version.
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
default: "staging"
type: choice
options:
- staging
- production
dry_run:
description: "Run without applying changes"
required: false
default: false
type: boolean
Each input has a type (string, boolean, number, choice, or environment), an optional default, and a required flag. Inside the workflow you read them from the inputs context - for example ${{ inputs.environment }}. This is how you build a safe, parameterized manual deploy instead of hard-coding values.
workflow_call: reusable workflows
workflow_call marks a workflow as callable by other workflows, turning it into a reusable building block. The calling workflow references it as a job's uses, passing inputs and secrets. The called workflow declares what it accepts.
on:
workflow_call:
inputs:
image_tag:
required: true
type: string
secrets:
registry_token:
required: true
A caller then invokes it like this:
jobs:
deploy:
uses: ./.github/workflows/deploy.yml
with:
image_tag: v1.4.0
secrets:
registry_token: ${{ secrets.REGISTRY_TOKEN }}
The difference between workflow_dispatch inputs and workflow_call inputs matters: dispatch inputs come from a human clicking a button, call inputs come from another workflow. A workflow can declare both if it needs to be runnable both ways.
jobs and runs-on
jobs is a map of independent units of work. Each key is a job ID (used later for dependencies), and each job runs on its own fresh runner - a clean virtual machine - in parallel with the others by default. Because runners are isolated, jobs share nothing automatically: no files, no environment, no state. If two jobs need to share data you have to pass it explicitly (covered under needs below).
Every job needs runs-on, which picks the runner. The common GitHub-hosted labels are ubuntu-latest, windows-latest, and macos-latest. Self-hosted runners use a self-hosted label plus any custom labels you assigned.
jobs:
build:
runs-on: ubuntu-latest
test:
runs-on: ubuntu-latest
Those two jobs run at the same time on two separate machines. ubuntu-latest is the default choice for almost everything - it is the fastest and cheapest. Reach for Windows or macOS only when you genuinely need them (native builds, platform-specific tests), because they cost more minutes and start slower.
steps: uses vs run
A job's steps is an ordered list, and this is where the actual work happens. Steps run sequentially in the same runner, so unlike jobs they do share the filesystem and, within one step's shell, the working directory. There are exactly two kinds of step, and the distinction is fundamental.
A step with uses runs a prebuilt action - a packaged, reusable unit someone else (or you) wrote, referenced by repository and version. A step with run executes shell commands directly on the runner. Most workflows alternate between the two: uses to pull in tooling, run to invoke it.
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install and test
run: |
npm ci
npm test
Two details in there. First, with: passes inputs to a uses action - setup-node takes a node-version, and how you learn what inputs an action accepts is its README. Second, the run: | uses a YAML block scalar (the |) to hold a multi-line script; every line runs in the same shell, so you can chain setup and commands. Always pin actions to a version tag (@v4) rather than a moving branch, so a surprise upstream change cannot break your pipeline.
Nearly every workflow starts with actions/checkout - the runner arrives with an empty workspace, and without checkout your own code is not there. Forgetting it is the most common first-workflow mistake.
env: variables at three scopes
env defines environment variables, and it can appear at three levels - workflow, job, and step - with the narrower scope winning. This layering lets you set a default once and override it where needed.
env:
LOG_LEVEL: info # applies to every job and step
jobs:
build:
runs-on: ubuntu-latest
env:
NODE_ENV: production # applies to every step in this job
steps:
- name: Show config
env:
LOG_LEVEL: debug # overrides the workflow-level value, here only
run: echo "$NODE_ENV / $LOG_LEVEL"
That echo prints production / debug: NODE_ENV comes from the job scope, and the step-level LOG_LEVEL shadows the workflow-level one just for this step. In a run step you read these as normal shell variables ($LOG_LEVEL). In YAML keys elsewhere in the file you read them through an expression, ${{ env.LOG_LEVEL }}, which is the next topic.
Expressions and contexts
${{ ... }} is the expression syntax, and inside it you have access to contexts - structured objects of information about the run. This is how a workflow reads its own circumstances: which branch, which event, who triggered it, what secrets are available. The contexts you use constantly:
github- everything about the event and repository:github.ref(the branch or tag ref),github.event_name(what triggered the run),github.sha(the commit),github.actor(who kicked it off),github.repository(owner/name).env- the environment variables defined withenv:, as shown above.secrets- encrypted secrets configured in the repo or organization:secrets.GITHUB_TOKEN(provided automatically), plus any you added likesecrets.REGISTRY_TOKEN.inputs- theworkflow_dispatchorworkflow_callinputs.needs- outputs from jobs this one depends on (covered below).
steps:
- name: Report
run: |
echo "Branch ref: ${{ github.ref }}"
echo "Triggered by: ${{ github.event_name }}"
echo "Commit: ${{ github.sha }}"
- name: Deploy
run: ./deploy.sh
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
The important discipline: never echo a secret or interpolate one into a log - pass it through an env: value as above so it stays masked. Expressions are evaluated by GitHub before the shell ever sees them, so ${{ github.ref }} is substituted with the real value at run time, then the command runs.
if: conditionals and status functions
Any step or job can carry an if: that decides whether it runs. The expression is evaluated, and if it is falsy the step or job is skipped (shown as skipped, not failed). Inside if: you do not need the ${{ }} wrapper - it is implied - though including it is harmless.
steps:
- name: Deploy to production
if: github.ref == 'refs/heads/main'
run: ./deploy.sh
That step only runs on the main branch; on any other branch it is skipped. This is how you keep a single workflow but gate the dangerous parts.
The subtlety is what happens after a failure. By default, once a step fails, later steps are skipped. To override that you use status check functions in the if::
success()- true if every previous step succeeded (this is the implicit default).failure()- true if a previous step failed. Use it for cleanup or notifications that should only run when things broke.always()- true no matter what, even after a failure or cancellation. Use it for teardown that must always happen.cancelled()- true if the run was cancelled.
steps:
- name: Run tests
run: npm test
- name: Upload logs on failure
if: failure()
run: ./collect-logs.sh
- name: Always clean up
if: always()
run: ./teardown.sh
Without those functions, the last two steps would be skipped the moment npm test failed. failure() and always() are what let you build reliable "report the problem" and "tear down the environment" steps that survive a broken pipeline. You can combine conditions too: if: always() && github.ref == 'refs/heads/main'.
needs: job dependencies and outputs
By default jobs run in parallel. needs declares that a job depends on one or more other jobs, forcing order - the dependent job waits until its dependencies complete successfully, and is skipped if any of them fail. This is how you build a pipeline: build, then test, then deploy.
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo "building"
test:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "testing"
deploy:
needs: [build, test]
runs-on: ubuntu-latest
steps:
- run: echo "deploying"
deploy waits for both build and test; test waits for build. The graph you draw with needs is the pipeline.
Because each job runs on its own fresh runner, they share no state - so when a later job needs a value the earlier one computed, you pass it explicitly through job outputs. The producing job declares outputs, mapping a name to a step's output; the step writes to the special $GITHUB_OUTPUT file; the consuming job reads it from the needs context.
jobs:
version:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.compute.outputs.tag }}
steps:
- id: compute
run: echo "tag=v1.4.0" >> "$GITHUB_OUTPUT"
publish:
needs: version
runs-on: ubuntu-latest
steps:
- run: echo "Publishing ${{ needs.version.outputs.tag }}"
Follow the wire: the step has an id (compute), it writes tag=v1.4.0 to $GITHUB_OUTPUT, the job exposes that as outputs.tag, and the downstream job reads it as needs.version.outputs.tag. That chain - step id, $GITHUB_OUTPUT, job outputs, needs context - is the only supported way to move a small value between jobs. For files rather than strings, you use artifacts (actions/upload-artifact and download-artifact) instead.
defaults: shared step settings
defaults sets default options for run steps so you do not repeat them. The most common use is shell and working-directory, at the workflow or job level.
defaults:
run:
shell: bash
working-directory: ./app
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci # runs inside ./app, using bash
Now every run step executes with bash from the ./app directory unless a step overrides it. This keeps a monorepo workflow tidy: set the subdirectory once instead of prefixing every command with cd app &&. Note that defaults.run only affects run steps - it has no effect on uses steps, which take their inputs through with:.
Putting the keys together
A workflow file is just these keys arranged deliberately. name labels it; on decides exactly when it fires, and its filters (branches, paths, cron, inputs) are where most surprises hide. jobs are parallel, isolated units, each pinned to a runner with runs-on, each a sequence of steps that are either a prebuilt action (uses plus with:) or a shell script (run, with | for multi-line). env layers variables across workflow, job, and step scopes; ${{ }} expressions read run information from contexts like github, secrets, and inputs. if: with success(), failure(), and always() gates what runs, especially after something breaks. needs turns independent jobs into an ordered pipeline, and job outputs move values along it. defaults trims the repetition. Read a workflow with those roles in mind and it stops being boilerplate you copy and becomes a file you can write from scratch. From here, the fundamentals guide fills in the surrounding model - runners, actions, and how a run actually executes.