GitHub Actions Workflows: the Structure of the File
New to workflows? Start with GitHub Actions Workflows: an Introduction.
A workflow file has three levels, one inside the other:
- the workflow
- its jobs
- each job's steps
Every key belongs to one or more of these levels, and a key in the wrong level is an error.
The workflows of a repository
A repository holds any number of workflows: every YAML file in its .github/workflows/ directory is one.
1.github/workflows/
2├── branch.yml on: push to any branch but main
3├── pr.yml on: pull_request to main
4├── main.yml on: push to main
5└── nightly.yml on: schedule
- An event starts every workflow whose
on:that matches it, each as a run of its own.- Two workflows started by one push run side by side, and neither waits for the other.
- A job's
needs:reaches only jobs in its own file. To start one workflow when another ends, the second nameson: workflow_runwith the first one's name.
- A workflow file is code in the repository, so every branch carries its own copy.
- A push runs the workflows as they are in the commit pushed, and a pull request as they are in the pull request's branch merged into its base.
- So a change to a workflow is tried on a branch, and reaches
mainthrough a merge like any other change. scheduleandworkflow_runare the exception: they always run the default branch's copy.
- There is no workflow per branch. One file serves every branch, and its filters choose the branches it
runs for:
branches: [main],branches-ignore: [main]. - How to divide the jobs into files is a choice; three are common:
- one file per event: what runs on a push to a branch, on a pull request, on a merge, each in its own file (the DevOps project does this);
- one file per purpose:
ci.yml(tests),security.yml(scanners),deploy.yml, each naming the events it needs; - one file for everything, its jobs choosing their events with
if:(see A complete workflow).
- What several files share goes into a reusable workflow or a composite action (see Actions: the
uses:key), not into copies.
The three levels of a workflow file
The levels are YAML nesting: a job is a key under jobs:, a step is an item in a job's steps: list.
1# ── workflow level: once per file ──────────────────────────────
2name: ci
3on: # the events that start the workflow
4 push:
5permissions: # what GITHUB_TOKEN may do, in every job
6 contents: read
7env: # environment variables for every job
8 APP_DIR: app
9
10jobs:
11 # ── job level: one block per job, under its id ───────────────
12 test:
13 runs-on: ubuntu-latest # the machine
14 needs: [] # the jobs it waits for
15 if: github.event_name == 'push' # run the job at all?
16 env: # variables for this job's steps
17 PYTHONUNBUFFERED: '1'
18 steps:
19 # ── step level: one list item per step ───────────────────
20 - name: Check out the code
21 uses: actions/checkout@v7 # an action
22 - name: Run the tests
23 id: tests
24 if: success() # run the step at all?
25 run: pytest # or shell commands
26 env: # variables for this step only
27 LOG_LEVEL: debug
- The workflow level holds what is true of the whole file: its name, its events, the defaults for every job.
- The job level holds what is true of one job: its machine, its order among the jobs, its condition.
- The step level holds one action or one shell script, with its own inputs, variables and condition.
- An inner level overrides an outer one: a step's
env:wins over its job's, which wins over the workflow's.
Where each key may appear
| Key | Workflow | Job | Step | What it holds |
|---|---|---|---|---|
name | ✓ | ✓ | ✓ | the name shown in the Actions tab |
run-name | ✓ | the name of each run, which may use expressions | ||
on | ✓ | the events that start the workflow | ||
permissions | ✓ | ✓ | what GITHUB_TOKEN may do | |
env | ✓ | ✓ | ✓ | environment variables |
defaults | ✓ | ✓ | the default shell and working-directory for run: steps | |
concurrency | ✓ | ✓ | a group in which only one run or job goes at a time | |
runs-on | ✓ | the runner: the machine the job runs on | ||
needs | ✓ | the jobs that must finish first | ||
if | ✓ | ✓ | a condition: skip the job or step when false | |
environment | ✓ | a deployment environment, with its own rules and secrets | ||
strategy | ✓ | a matrix: the job run once per combination of values | ||
services | ✓ | containers running beside the job, such as a database | ||
container | ✓ | a container to run the job's steps in | ||
outputs | ✓ | values the job hands to the jobs that need it | ||
steps | ✓ | the job's steps | ||
uses | ✓ | ✓ | a step: an action; a job: a reusable workflow | |
with | ✓ | ✓ | the inputs of that action or reusable workflow | |
secrets | ✓ | the secrets passed to a reusable workflow | ||
timeout-minutes | ✓ | ✓ | the time after which it is cancelled | |
continue-on-error | ✓ | ✓ | let it fail without failing the run | |
run | ✓ | shell commands | ||
id | ✓ | the name later steps read its outputs by | ||
shell, working-directory | ✓ | how and where run: runs | ||
background, parallel, wait | ✓ | steps running side by side |
The full list, with every option, is GitHub's workflow syntax reference.
Events: the on: key
Events appear in one place only: the on: key at the top of the file. They start the whole workflow, every job in
it.
A job or a step has no events of its own:
on:inside a job is an error. What a job or a step has is the event that started the run, which it reads in anif:(see Events below the workflow level).on:takes three forms:1on: push # one event 2 3on: [push, pull_request] # several events 4 5on: # several events, each narrowed down 6 push: 7 branches: [main] 8 pull_request: 9 types: [opened, synchronize]
Event filters
An event's filters decide whether this occurrence of it starts the workflow.
| Filter | Events | Matches |
|---|---|---|
branches, branches-ignore | push, pull_request | push: the branch pushed to; pull_request: the branch the pull request targets, its base |
tags, tags-ignore | push | the tag pushed |
paths, paths-ignore | push, pull_request | the files the commits changed |
types | most events | the activity: a pull request opened, synchronized (new commits), reopened, closed, … |
- Two filters on one event must both match:
branches: [main]withpaths: ['app/**']starts only on a push tomainthat changed a file underapp/. pull_requestwithouttypes:starts onopened,synchronizeandreopenedonly. A merge is aclosedpull request, and it starts nothing unless named; most workflows watch thepushtomaininstead.scheduleruns on the default branch's latest commit, whatever branch the file was written on.workflow_dispatchadds a Run workflow button, and may ask forinputs:, read asinputs.<name>.workflow_callmakes the file a reusable workflow, started by a job in another workflow (see Actions: theuses:key).
The event inside the run
The run knows the event that started it, through the github context:
| Expression | Holds | Example |
|---|---|---|
github.event_name | the event | push, pull_request |
github.ref | the full name of the branch or tag | refs/heads/main; on a pull request, refs/pull/7/merge |
github.ref_name | the short name | main, 7/merge |
github.sha | the commit | on a pull request, a test merge of the branch into its base |
github.event | the event's whole payload | github.event.pull_request.number |
Events below the workflow level
The workflow level names the events; a job or a step chooses among them with an if: on the github context.
The run starts on any event in
on:, and then each job and step decides whether it takes part:1on: [push, pull_request] 2 3jobs: 4 test: # every event 5 runs-on: ubuntu-latest 6 steps: 7 - run: pytest 8 - if: github.event_name == 'pull_request' # one step, on a pull request only 9 run: pytest tests/integration 10 deploy: 11 if: github.event_name == 'push' && github.ref == 'refs/heads/main' # one job, on a push to main only 12 runs-on: ubuntu-latest 13 steps: 14 - run: ./deploy.shA job skipped by its
if:still shows in the run, marked skipped; the run itself started.The activity is a condition too:
github.event.action == 'opened'runs a step only when a pull request is opened, out of thetypes:the workflow listed.branchesandpathshave no job-level form.- A branch is a condition on
github.ref, as indeployabove. - Files changed are not in the context: a first job compares the commits (with
git diff, or an action such asdorny/paths-filter), writes the result as an output, and the later jobs read it in theirif:(see Data between steps and jobs).
- A branch is a condition on
A reusable workflow has no events of its own either: its jobs see the caller's event, so
github.event_nameinside it ispushorpull_request, neverworkflow_call.Two files instead of one is the other way to give jobs different events: each file's
on:names its own, and noif:is needed.
Actions: the uses: key
uses: appears at two levels, and means a different thing at each:
- In a step, it runs an action: a packaged step.
- In a job, it runs a reusable workflow: a whole other workflow file, in place of the job's own steps.
An action, in a step
| Form | Where the action comes from | Example |
|---|---|---|
owner/repo@ref | a repository on GitHub, at a tag, branch or commit | actions/checkout@v7 |
owner/repo/path@ref | a directory inside such a repository | github/codeql-action/analyze@v4 |
./path | a directory in this repository | ./.github/actions/setup |
docker://image | a container image, run as the step | docker://alpine:3.22 |
- An action is a directory holding an
action.yml, which names its inputs and outputs, and how it runs:- a JavaScript action runs a Node script on the runner;
- a container action runs a Docker image;
- a composite action runs a list of steps, like a job's.
- Its inputs go under
with:, its outputs are read assteps.<id>.outputs.<name>. - A local action needs the code on the runner:
actions/checkoutmust run before a./pathstep. - The
@refis trusted code. A tag can be moved to other code by the action's owner; a full commit SHA cannot:actions/checkout@<40 hex digits>. Pinning to a SHA is a security practice in its own right.
A reusable workflow, in a job
1jobs:
2 deploy:
3 uses: ./.github/workflows/deploy.yml # or owner/repo/.github/workflows/deploy.yml@v1
4 with:
5 environment: production
6 secrets: inherit
- The job holds no
runs-on:and nosteps:: the called file has its own jobs, each with its own runner. - The called file says
on: workflow_call, and declares theinputs:andsecrets:it takes. secrets: inheritpasses the caller's secrets on; otherwise each one is passed by name.- A composite action repeats steps; a reusable workflow repeats jobs. Use the first for "set up Python and install the requirements", the second for "build, scan and push the image".
Conditions: the if: key
if: appears on a job or on a step, and skips it when the expression is false. A job that needs a skipped
job is skipped as well, unless its own if: holds a status function (see Status functions).
1 deploy:
2 needs: test
3 if: github.event_name == 'push' && github.ref == 'refs/heads/main'
- The
${{ }}around anif:is optional:if:is always an expression. - Contexts read in a condition:
github,env,vars,inputs,needs,steps,matrix,job,runner. - Secrets cannot be read in
if:directly: put the secret in the job'senv:, and test the variable.
Status functions
Every if: without a status function has an implicit success(): the job or step runs only if everything
before it succeeded.
| Function | Runs when |
|---|---|
success() | every earlier step, or every needed job, succeeded: the default |
failure() | one of them failed |
always() | in any case, even when the run is cancelled |
cancelled() | the run was cancelled |
1 - name: Report the failure
2 if: failure()
3 run: echo "A step before this one failed"
Data between steps and jobs
Steps of one job share a machine; jobs do not, so each passes data its own way.
- Between steps of one job:
- files: the steps share the runner's disk and working directory;
- environment variables: a line
NAME=valueappended to the file$GITHUB_ENVsets$NAMEfor every later step; - outputs: a line
name=valueappended to$GITHUB_OUTPUTis read by later steps assteps.<id>.outputs.name.
- Between jobs:
- outputs: the job's
outputs:names step outputs, and a job thatneedsit reads them asneeds.<job>.outputs.<name>; - files: an artifact, uploaded by
actions/upload-artifact@v7in one job and downloaded byactions/download-artifact@v8in the next.
- outputs: the job's
1jobs:
2 version:
3 runs-on: ubuntu-latest
4 outputs:
5 tag: ${{ steps.tag.outputs.tag }}
6 steps:
7 - id: tag
8 run: echo "tag=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
9
10 build:
11 needs: version
12 runs-on: ubuntu-latest
13 steps:
14 - run: echo "Building image tag ${{ needs.version.outputs.tag }}"
Steps running side by side
A step with background: true starts and lets the job go on to the next step; wait: names the background
steps to wait for.
1 - name: Start the API
2 id: api
3 run: gunicorn app.main:app --bind 127.0.0.1:8000
4 background: true
5
6 - name: Call it
7 run: curl --retry 10 --retry-connrefused http://127.0.0.1:8000/healthz
8
9 - name: Stop the API
10 cancel: api
parallel:holds a group of steps that run at once, and waits for all of them before the next step.- At most 10 background steps run at a time in a job.
Job-level keys that shape a run
strategy: one job, many runs
A matrix runs the job once for each combination of its values, side by side.
1 test:
2 runs-on: ubuntu-latest
3 strategy:
4 fail-fast: false
5 matrix:
6 python-version: ['3.12', '3.13', '3.14']
7 steps:
8 - uses: actions/checkout@v7
9 - uses: actions/setup-python@v7
10 with:
11 python-version: ${{ matrix.python-version }}
12 - run: pytest
- Each combination is a job of its own in the run:
test (3.12),test (3.13),test (3.14). fail-fast: falselets the others finish when one fails; by default the rest are cancelled.
services: containers beside the job
A service is a container GitHub starts before the job's steps and removes after them.
1 integration:
2 runs-on: ubuntu-latest
3 services:
4 postgres:
5 image: postgres:18
6 env:
7 POSTGRES_PASSWORD: test
8 ports:
9 - 5432:5432
10 steps:
11 - run: pg_isready -h localhost -p 5432
- A job running on the runner itself reaches the service at
localhostand the mapped port. - Services run on Linux runners only.
permissions, environment, concurrency: who may do what, and when
permissions:sets what the job'sGITHUB_TOKENmay do, scope by scope:contents: read,pull-requests: write,id-token: write(to ask for an OIDC token, as in Pipeline Identities, Part 3).- At workflow level it applies to every job; a job's own replaces it for that job.
- Scopes not named are
none.
environment:ties the job to a deployment environment, set up under Settings → Environments.- The environment's rules apply before the job starts: required reviewers, a wait timer, the branches allowed.
- Its own secrets and variables are readable only by jobs that name it.
concurrency:puts runs or jobs into a named group, of which only one runs at a time.cancel-in-progress: truecancels the running one when a newer one starts.group: deploy-${{ github.ref }}keeps one deployment per branch at a time.
A complete workflow
One file, two events, and jobs that run on only one of them.
1name: ci
2run-name: ci for ${{ github.event_name }} on ${{ github.ref_name }}
3
4on:
5 push:
6 branches: [main]
7 pull_request:
8 branches: [main]
9
10permissions:
11 contents: read
12
13jobs:
14 test:
15 runs-on: ubuntu-latest
16 strategy:
17 matrix:
18 python-version: ['3.13', '3.14']
19 steps:
20 - uses: actions/checkout@v7
21 - uses: actions/setup-python@v7
22 with:
23 python-version: ${{ matrix.python-version }}
24 cache: pip
25 - run: pip install -r requirements-dev.txt
26 - run: pytest
27
28 image:
29 needs: test
30 runs-on: ubuntu-latest
31 outputs:
32 tag: ${{ steps.tag.outputs.tag }}
33 steps:
34 - uses: actions/checkout@v7
35 - id: tag
36 run: echo "tag=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
37 - run: docker build -t books:${{ steps.tag.outputs.tag }} .
38
39 deploy:
40 needs: image
41 if: github.event_name == 'push'
42 runs-on: ubuntu-latest
43 environment: production
44 concurrency:
45 group: deploy
46 permissions:
47 contents: read
48 id-token: write
49 steps:
50 - run: echo "Deploying books:${{ needs.image.outputs.tag }}"
51
52 report:
53 needs: [test, image, deploy]
54 if: failure()
55 runs-on: ubuntu-latest
56 steps:
57 - run: echo "Something in this run failed"
- On a pull request to
main:testruns twice, once per Python version, thenimagebuilds;deployis skipped by itsif:. - On a push to
main, a merge included: the same, thendeploy, after theproductionenvironment's rules allow it, and with anid-tokenit may trade for cloud credentials. reportruns only when one of the jobs it needs failed.- The DevOps project builds the same jobs another way: one workflow file per event, in Task 2.