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:

yaml
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 test
Watch out for on 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:

yaml
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:

yaml
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
Always use | 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:

yaml
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.sh

The 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:

yaml
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 test

This 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:

yaml
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 production

Reusable 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:

yaml
# .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 }}
yaml
# .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: 20 passes an integer; some actions expect a string. Use node-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: false in 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.