GitHub Actions Workflow Syntax

By Vishvesh Patel · DevOps EngineerUpdated September 28, 2026

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.

Continue Learning

Explore Related Topics

Try the Tool

When it breaks

Related Resources