Every GitHub Actions workflow you've written is a YAML file. All of it — the triggers, the jobs, the steps, the environment variables, the matrix strategy — is expressed in YAML. Which means understanding YAML deeply isn't just academic: it's the difference between CI that works reliably and CI that breaks mysteriously at 4pm on a Friday when you're trying to ship a release.
GitHub Actions has excellent workflow syntax docs, but they focus on the Actions features rather than the YAML mechanics underneath. This article covers both — the workflow structure, and the YAML-specific patterns (and pitfalls) that make or break CI configs.
Workflow Structure: The Top-Level Keys
A GitHub Actions workflow file lives in .github/workflows/ and has three required top-level keys.
The official workflow overview
explains where the file sits in your repo and how GitHub discovers it:
name: CI Pipeline # optional but shown in the Actions UI
on: # triggers
push:
branches: [main]
pull_request:
jobs: # the actual work
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm teston as a YAML boolean: In YAML 1.1, the bare word on
is interpreted as boolean true. GitHub's parser handles this correctly, but if you ever see your
workflow file flagged by a generic YAML linter, this is why. The safe form is to quote it:
"on": — though GitHub Actions itself doesn't require the quotes.Triggers: The on: Key
The on: key controls when your workflow runs. The
full list of trigger events
covers everything from pull request reviews to repository dispatches. Here are the most common patterns:
on:
# Push to specific branches
push:
branches:
- main
- "release/**" # glob pattern — quotes needed for /**
paths-ignore:
- "docs/**"
- "*.md"
# PRs targeting main
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
# Scheduled (cron syntax)
schedule:
- cron: "0 2 * * 1" # every Monday at 2am UTC
# Manual trigger with input
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
default: staging
type: choice
options: [staging, production]Multi-Line run: Commands and YAML Block Scalars
The run: key is where YAML's literal block scalar (|) earns its keep —
see the yaml-multiline.info reference for
every variation of block and folded scalars. Without it, you'd be chaining commands with &&
on a single line, which is unreadable:
steps:
# Single line — works but hard to read
- name: Build
run: npm ci && npm run lint && npm run test && npm run build
# Multi-line with literal block scalar — much better
- name: Build
run: |
npm ci
npm run lint
npm run test -- --coverage
npm run build
# Folded scalar (>) joins lines with spaces — NOT what you want for shell
# This would run as a single command with spaces where the newlines are:
- name: Wrong for shell
run: >
npm ci
npm test| for multi-line shell scripts, never >.
The folded scalar folds newlines into spaces, which means your shell commands get joined into one long string.
That causes cryptic errors. The literal block scalar | preserves newlines exactly, so each line
runs as a separate shell command.Environment Variables and Secrets
Environment variables in GitHub Actions use YAML in combination with the Actions context and expression syntax. Here's how they fit together:
env:
NODE_ENV: production # workflow-level env var
API_VERSION: "2.1" # quoted to force string type
jobs:
deploy:
runs-on: ubuntu-latest
env:
DEPLOY_ENV: staging # job-level env var (overrides workflow-level)
steps:
- name: Deploy to staging
env:
API_KEY: ${{ secrets.STAGING_API_KEY }} # step-level, from secret
DATABASE_URL: ${{ vars.STAGING_DB_URL }} # step-level, from variable
run: |
echo "Deploying to $DEPLOY_ENV"
./scripts/deploy.shThe expression syntax ${{ }} is GitHub Actions' own template language layered on top of YAML.
It's evaluated before YAML parsing in some contexts, which can interact unexpectedly with YAML's own quoting rules.
When in doubt, wrap values that start with ${{ in double quotes.
Matrix Strategy: Build Across Multiple Configs
The matrix strategy is one of GitHub Actions' most powerful features — and it's expressed entirely in YAML. It runs your job once for each combination of matrix values:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # don't cancel other matrix jobs on failure
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: ["18", "20", "22"]
exclude:
- os: windows-latest
node: "18" # skip this combination
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci && npm testThis single job definition produces up to 9 parallel runs (3 OS × 3 Node versions, minus 1 excluded).
The YAML matrix values are plain arrays — the only thing to watch is that version numbers like 18
should be quoted as "18" to keep them as strings. Without quotes, YAML parses them as integers
and some Actions may complain.
Conditional Steps with if:
The if: key lets you skip steps based on conditions. It uses GitHub's expression language,
not plain YAML values:
steps:
- name: Run tests
run: npm test
- name: Upload coverage
if: success() && github.ref == 'refs/heads/main'
uses: codecov/codecov-action@v4
- name: Notify on failure
if: failure()
uses: slackapi/slack-github-action@v1
with:
slack-message: "Build failed on ${{ github.ref }}"
channel-id: "C12345ABC"
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
- name: Deploy (only on tag push)
if: startsWith(github.ref, 'refs/tags/v')
run: ./scripts/deploy.sh productionReusable Workflows
For eliminating duplication across repositories (not just within a file), GitHub Actions supports
reusable workflows.
The called workflow uses on: workflow_call: and the caller uses
uses: at the job level — not the step level:
# .github/workflows/reusable-test.yml (the reusable workflow)
on:
workflow_call:
inputs:
node-version:
required: false
type: string
default: "20"
secrets:
NPM_TOKEN:
required: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- run: npm ci && npm test
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}# .github/workflows/ci.yml (the caller)
on: [push, pull_request]
jobs:
run-tests:
uses: my-org/shared-workflows/.github/workflows/reusable-test.yml@main
with:
node-version: "22"
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}The Most Common YAML Mistakes in GitHub Actions
- Wrong indentation on steps. Steps must be indented under
steps:with consistent spaces. A single extra or missing space moves a step outside its job. - Unquoted version numbers.
node-version: 20passes an integer; some actions expect a string. Usenode-version: "20"to be safe. - Using
>instead of|for shell scripts. Folded scalars collapse newlines. Your multi-line script becomes one long string and fails. - Missing quotes on glob patterns. Patterns like
release/**contain*which YAML may interpret as an alias. Always quote glob patterns. - Secrets used in if: conditions. GitHub masks secrets in logs but doesn't allow them in
if:expressions. Use an environment variable instead. - Forgetting
fail-fast: falsein matrix jobs. By default, if one matrix job fails, all others are cancelled. Usually not what you want during debugging.
Wrapping Up
GitHub Actions workflows are fundamentally YAML files — understanding YAML's literal block scalars, quoting rules, and type coercion directly translates to writing more reliable CI. The most common failures come from indentation mistakes, unquoted values that coerce to the wrong type, and using the wrong multi-line string operator for shell scripts. Get those three things right and most workflow debugging becomes straightforward. Use the YAML Validator to catch syntax errors before pushing, and the YAML Formatter to enforce consistent indentation across your workflow files.