Workflow Syntax: Triggers, Jobs, Steps and Expressions
How a GitHub Actions workflow file works: triggers and filters, manual inputs, jobs and needs, job outputs, contexts, expressions and if conditions.
Part 1's workflow ran one job on every push. Real workflows decide when to run, take inputs, split work into jobs that depend on each other and pass values between them. This part covers the syntax for all of that by extending hello.yml into two jobs that share a message, and shows how to check a workflow for mistakes before you push it.
Prerequisites
- The repository from Part 1 with
.github/workflows/hello.yml. - Optional: actionlint to check workflows locally. With shellcheck installed, it also checks the scripts in
run:steps.
The New hello.yml
Replace the workflow with this version:
name: Hello
on:
push:
branches: [main]
paths-ignore:
- '**.md'
workflow_dispatch:
inputs:
name:
description: Who to greet
type: string
default: world
shout:
description: Print the greeting in capitals
type: boolean
default: false
permissions:
contents: read
env:
GREETING: Hello
jobs:
greet:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
message: ${{ steps.compose.outputs.message }}
steps:
- name: Compose the message
id: compose
env:
NAME: ${{ inputs.name || 'world' }}
SHOUT: ${{ inputs.shout }}
run: |
message="$GREETING, $NAME!"
if [ "$SHOUT" = "true" ]; then
message="${message^^}"
fi
echo "message=$message" >> "$GITHUB_OUTPUT"
report:
needs: greet
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Print the message
env:
MESSAGE: ${{ needs.greet.outputs.message }}
run: echo "$MESSAGE"
- name: Runs only when started by hand
if: github.event_name == 'workflow_dispatch'
run: echo "Started from the Actions tab or with gh workflow run"
The two jobs and the value that flows between them:
Triggers
The on: key decides when the workflow runs. This version narrows the push trigger with two filters: branches: [main] ignores pushes to other branches, and paths-ignore skips pushes that change only Markdown files. paths does the opposite and runs only when matching files change. Both filters also work on pull_request.
These are the triggers you will use most:
| Trigger | Runs when | Used in this guide |
|---|---|---|
push |
Commits are pushed to a branch or a tag is pushed | Parts 1, 2 and 6 |
pull_request |
A pull request is opened, updated or reopened | Part 3 |
workflow_dispatch |
Someone clicks "Run workflow" or runs gh workflow run |
Parts 2 and 5 |
schedule |
A cron schedule matches (UTC unless you set timezone) |
Not used; see the note below |
workflow_call |
Another workflow calls this one as a reusable workflow | Part 7 |
merge_group |
A pull request enters a merge queue | Part 7 |
pull_request_target |
Like pull_request, but with the base repository's secrets |
Part 9 explains why to avoid it |
workflow_dispatch takes up to 25 inputs (the limit was 10 until December 2025) of type string, boolean, choice, number or environment. The "Run workflow" button only appears once the workflow file is on the default branch.
A schedule runs on the default branch, at most every 5 minutes, and is often delayed a few minutes at busy times such as the top of the hour. Since March 2026 you can add a timezone, for example timezone: 'Europe/Berlin' next to cron: '30 5 * * 1-5'. In a public repository, GitHub disables scheduled workflows after 60 days without any repository activity.
Jobs, Needs and Outputs
Jobs run in parallel by default. needs: greet makes report wait for greet, and skips report if greet fails. timeout-minutes stops a job that hangs; the default is 360 minutes (6 hours), or less where the runner has a lower limit, such as 15 minutes on ubuntu-slim.
A job can pass strings to later jobs in three steps:
- A step with an
id(compose) writesname=valuelines to the file in$GITHUB_OUTPUT. - The job maps that step output to a job output:
message: ${{ steps.compose.outputs.message }}. - A job that
needsit reads${{ needs.greet.outputs.message }}.
Job outputs are always strings. For structured data, write JSON and read it with fromJSON(). For files, use artifacts (Part 4).
Contexts and Expressions
Anything inside ${{ }} is an expression that GitHub evaluates before the step runs. Expressions read contexts: github (the event, ref, SHA, repository), env, vars and secrets (Part 5), inputs, needs, steps, matrix (Part 3), runner and job. Operators such as ==, &&, || and ! and functions such as contains(), startsWith(), format(), toJSON(), fromJSON() and hashFiles() work inside them. Since January 2026 there is also case(), which returns the value of the first true condition:
env:
TARGET: ${{ case(github.ref == 'refs/heads/main', 'production', 'development') }}
${{ inputs.name || 'world' }} uses || to fall back to world on a push, where there are no inputs.
Notice that the workflow never writes ${{ inputs.name }} directly into the shell script. It passes the value through env: and the script uses $NAME. GitHub pastes expressions into the script text before Bash runs it, so a value such as "; rm -rf ~; echo " would become code. Through an environment variable it stays data. Part 9 covers this script injection problem.
Conditions
if: decides whether a job or step runs. It is an expression, so ${{ }} is optional there. The last step of report runs only for manual runs.
By default, a step runs only if all earlier steps succeeded. Status functions change that:
| Condition | The step runs when |
|---|---|
success() (default) |
Every earlier step succeeded |
failure() |
An earlier step failed |
always() |
Always, even when the run is canceled |
!cancelled() |
Always, unless the run is canceled; usually what you want for reports |
continue-on-error: true on a step lets the job go on after that step fails, and the job still counts as successful. Use it rarely: it hides failures.
Run It by Hand
Push the new file, then start a manual run with both inputs:
The greet job composed the message from the inputs, and report printed it. On the push run, the last step was skipped because the event was push. gh run view --job shows skipped steps with a dash:
Check a Workflow Before You Push
A typo in a workflow often shows up only when the run fails, or worse, when a condition silently never matches. actionlint checks workflow files locally: YAML structure, expressions, job dependencies, input names, and with shellcheck, the scripts in run:. Here it is on a copy of hello.yml with three typical mistakes: needs: gret, inputs.nmae and an unquoted $MESSAGE:
actionlint found all three, plus a follow-up error: needs.greet is not available because report does not need greet. On the real hello.yml it prints nothing and exits with status 0:
Run actionlint in your editor or as a pre-commit hook. Part 9 adds zizmor, which looks for security problems that actionlint does not.
Common Mistakes
- Writing
${{ }}intorun:scripts. It works until a value contains a quote. Pass values throughenv:. - Comparing booleans as booleans everywhere. Inputs of type
booleanare real booleans ininputs, but in environment variables and job outputs everything is a string: compare with"true". - A job output that is always empty. The step needs an
id, and the job needs anoutputs:entry that points to it. - Expecting
workflow_dispatchon a branch. The button andgh workflow runneed the workflow file on the default branch. - Overlapping triggers.
on: [push, pull_request]without filters runs twice for every push to a pull request branch.
Summary
on:withbranches,pathsandpaths-ignorefilters decides when a workflow runs;workflow_dispatchadds manual runs with up to 25 inputs.- Jobs run in parallel unless they
needeach other; job outputs pass strings between jobs. - Expressions read contexts such as
github,inputsandneeds; pass them to scripts throughenv:. if:withsuccess(),failure(),always()and!cancelled()controls what runs after a failure.- actionlint catches typos and script bugs before you push.
In Part 3 you add the example app and a CI workflow that lints, tests and builds it on every pull request.
Keep Learning
Tags
Found an issue?