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 names on: workflow_run with 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 main through a merge like any other change.
    • schedule and workflow_run are 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

KeyWorkflowJobStepWhat 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 an if: (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.

FilterEventsMatches
branches, branches-ignorepush, pull_requestpush: the branch pushed to; pull_request: the branch the pull request targets, its base
tags, tags-ignorepushthe tag pushed
paths, paths-ignorepush, pull_requestthe files the commits changed
typesmost eventsthe activity: a pull request opened, synchronized (new commits), reopened, closed, …
  • Two filters on one event must both match: branches: [main] with paths: ['app/**'] starts only on a push to main that changed a file under app/.
  • pull_request without types: starts on opened, synchronize and reopened only. A merge is a closed pull request, and it starts nothing unless named; most workflows watch the push to main instead.
  • schedule runs on the default branch's latest commit, whatever branch the file was written on.
  • workflow_dispatch adds a Run workflow button, and may ask for inputs:, read as inputs.<name>.
  • workflow_call makes the file a reusable workflow, started by a job in another workflow (see Actions: the uses: key).

The event inside the run

The run knows the event that started it, through the github context:

ExpressionHoldsExample
github.event_namethe eventpush, pull_request
github.refthe full name of the branch or tagrefs/heads/main; on a pull request, refs/pull/7/merge
github.ref_namethe short namemain, 7/merge
github.shathe commiton a pull request, a test merge of the branch into its base
github.eventthe event's whole payloadgithub.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.sh
    
  • A 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 the types: the workflow listed.

  • branches and paths have no job-level form.

    • A branch is a condition on github.ref, as in deploy above.
    • Files changed are not in the context: a first job compares the commits (with git diff, or an action such as dorny/paths-filter), writes the result as an output, and the later jobs read it in their if: (see Data between steps and jobs).
  • A reusable workflow has no events of its own either: its jobs see the caller's event, so github.event_name inside it is push or pull_request, never workflow_call.

  • Two files instead of one is the other way to give jobs different events: each file's on: names its own, and no if: 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

FormWhere the action comes fromExample
owner/repo@refa repository on GitHub, at a tag, branch or commitactions/checkout@v7
owner/repo/path@refa directory inside such a repositorygithub/codeql-action/analyze@v4
./patha directory in this repository./.github/actions/setup
docker://imagea container image, run as the stepdocker://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 as steps.<id>.outputs.<name>.
  • A local action needs the code on the runner: actions/checkout must run before a ./path step.
  • The @ref is 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 no steps:: the called file has its own jobs, each with its own runner.
  • The called file says on: workflow_call, and declares the inputs: and secrets: it takes.
  • secrets: inherit passes 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 an if: 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's env:, 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.

FunctionRuns 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=value appended to the file $GITHUB_ENV sets $NAME for every later step;
    • outputs: a line name=value appended to $GITHUB_OUTPUT is read by later steps as steps.<id>.outputs.name.
  • Between jobs:
    • outputs: the job's outputs: names step outputs, and a job that needs it reads them as needs.<job>.outputs.<name>;
    • files: an artifact, uploaded by actions/upload-artifact@v7 in one job and downloaded by actions/download-artifact@v8 in the next.
 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: false lets 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 localhost and the mapped port.
  • Services run on Linux runners only.

permissions, environment, concurrency: who may do what, and when

  • permissions: sets what the job's GITHUB_TOKEN may 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: true cancels 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: test runs twice, once per Python version, then image builds; deploy is skipped by its if:.
  • On a push to main, a merge included: the same, then deploy, after the production environment's rules allow it, and with an id-token it may trade for cloud credentials.
  • report runs 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.