Module 1: Introduction to CI/CD and GitHub Actions

4. YAML Syntax for Workflows

Overview

YAML is GitHub Actions' configuration language. It's not code that runs — it's a declarative specification that tells GitHub Actions what to do. If you come from Python, the indentation will feel familiar. But YAML has its own rules, and a single extra space (or missing one) can break your whole workflow.

This capsule teaches you YAML specifically for GitHub Actions: the structures you'll use most, the most common errors, and how to avoid them. By the end, you'll be able to read and write workflow YAML without fighting the indentation.


YAML in 5 minutes: The essentials

Rule #1: Indentation with spaces (NEVER tabs)

# ✅ Correct: 2 spaces of indentation
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: echo "hello"

# ❌ Incorrect: tabs
jobs:
	test:                    # TAB → Parsing error
		runs-on: ubuntu-latest

GitHub Actions uses 2 spaces per indentation level by convention. You can use 4, but 2 is the standard.

Tip: Configure your editor to convert tabs to spaces automatically in .yml/.yaml files.

Rule #2: Key-Value pairs

The basic structure is key: value:

name: CI Pipeline              # string
on: push                       # string
timeout-minutes: 30            # number
continue-on-error: true        # boolean

Important: There's always a space after the colon: key: value, not key:value.

Rule #3: Nested maps (objects)

A map is a collection of key-value pairs. You nest with indentation:

jobs:                          # Level 1 map
  test:                        # Level 2 map
    runs-on: ubuntu-latest     # Key-value inside the map
    timeout-minutes: 10

This is equivalent to a dictionary in Python:

{
    "jobs": {
        "test": {
            "runs-on": "ubuntu-latest",
            "timeout-minutes": 10
        }
    }
}

Rule #4: Lists (arrays)

Lists use a dash (-) with indentation:

# Simple list
branches:
  - main
  - develop
  - "feature/*"

# List of maps (the most common thing in workflows)
steps:
  - name: Checkout
    uses: actions/checkout@v4
  - name: Run tests
    run: pytest tests/

Each - marks a new element of the list. In steps, each step starts with - .

Rule #5: Strings

# Simple string (no quotes is fine)
name: CI Pipeline
runs-on: ubuntu-latest

# String with special characters → use quotes
python-version: "3.12"        # Without quotes, YAML reads it as the number 3.12
node-version: "18"            # Without quotes, YAML reads it as the number 18

# Multi-line strings with pipe (|)
run: |
  echo "Line 1"
  echo "Line 2"
  pip install -r requirements.txt

# Multi-line strings that collapse into one line (>)
description: >
  This is a long description
  that wraps across multiple lines
  but becomes a single line.

Golden rule: If the value starts with a number or contains special characters (*, {, }, :, #), use quotes.


Structure of a complete workflow

Let's look at a real workflow broken down:

# ──────────── METADATA ────────────
name: CI Pipeline                         # Workflow name (shows up in the UI)

# ──────────── TRIGGERS ────────────
on:                                       # When it runs
  push:
    branches: [main, develop]             # Only on these branches
  pull_request:
    branches: [main]                      # Only PRs to main

# ──────────── ENVIRONMENT ────────────
env:                                      # Global environment variables
  PYTHON_VERSION: "3.12"
  CI: true

# ──────────── JOBS ────────────
jobs:
  lint:                                   # First job
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}
      - run: pip install ruff
      - run: ruff check src/

  test:                                   # Second job (parallel to lint)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}
      - run: pip install -r requirements.txt
      - run: pytest tests/ -v

  build:                                  # Third job (sequential)
    needs: [lint, test]                   # Waits for lint AND test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: docker build -t myapp .

Sections of the workflow

SectionRequiredWhat it defines
nameNo (recommended)Name visible in the UI
onYesWhen the workflow runs
envNoGlobal environment variables
jobsYesThe jobs that make up the workflow

Common YAML patterns in workflows

Pattern 1: with — Pass parameters to actions

steps:
  - uses: actions/setup-python@v5
    with:
      python-version: "3.12"           # Parameter of the action
      cache: "pip"                     # Another parameter

with is a key-value map that configures the action. Each action defines which parameters it accepts.

Pattern 2: env — Environment variables

# Workflow level (available in all jobs)
env:
  PYTHON_VERSION: "3.12"

jobs:
  test:
    # Job level (available in all the job's steps)
    env:
      DATABASE_URL: "sqlite:///test.db"
    runs-on: ubuntu-latest
    steps:
      - name: Test with variable
        # Step level (only this step)
        env:
          DEBUG: "true"
        run: pytest tests/ -v

Variables are resolved in order: step > job > workflow. If you define DEBUG at all three levels, the step uses the step's value.

Pattern 3: if — Conditional execution

jobs:
  deploy:
    if: github.ref == 'refs/heads/main'    # Only on the main branch
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying..."

  notify:
    if: failure()                           # Only if something failed
    runs-on: ubuntu-latest
    steps:
      - run: echo "Something failed!"

Pattern 4: ${{ }} expressions

steps:
  - name: Show branch
    run: echo "Branch is ${{ github.ref }}"

  - name: Show event
    run: echo "Event is ${{ github.event_name }}"

  - name: Use secret
    run: echo "Using API key"
    env:
      API_KEY: ${{ secrets.OPENAI_API_KEY }}

${{ }} expressions access GitHub Actions' context: information about the repo, the event, secrets, and more.

Pattern 5: Multi-line commands

steps:
  - name: Setup and test
    run: |
      python -m venv .venv
      source .venv/bin/activate
      pip install -r requirements.txt
      pytest tests/ -v --tb=short

The pipe (|) preserves line breaks. Each line runs as a separate command in the shell.


The 7 most common errors in workflow YAML

Error 1: Incorrect indentation

# ❌ "steps" must be indented under the job
jobs:
  test:
    runs-on: ubuntu-latest
  steps:                         # Error: badly indented
    - run: echo "hello"

# ✅ Correct
jobs:
  test:
    runs-on: ubuntu-latest
    steps:                       # Indented under "test"
      - run: echo "hello"

Error 2: Tabs instead of spaces

# ❌ Silent error: YAML doesn't accept tabs
jobs:
 test:                          # Tab → Parsing error
  runs-on: ubuntu-latest

# ✅ Use spaces
jobs:
  test:
    runs-on: ubuntu-latest

Tip: Enable "show whitespace" in your editor to spot tabs.

Error 3: Missing quotes on versions

# ❌ YAML reads 3.12 as a float number (3.119999...)
python-version: 3.12

# ✅ With quotes it's the string "3.12"
python-version: "3.12"

This is especially problematic with versions like 3.10 — without quotes, YAML reads it as 3.1.

Error 4: Forgetting the space after :

# ❌ No space → YAML treats it as the string "runs-on:ubuntu-latest"
runs-on:ubuntu-latest

# ✅ With a space
runs-on: ubuntu-latest

Error 5: Mixing inline and expanded arrays

# Inline style (one line)
branches: [main, develop]

# Expanded style (multi-line)
branches:
  - main
  - develop

# ❌ DON'T mix them
branches: [main,
  - develop]                     # Syntax error

Use one or the other, not both.

Error 6: String with special characters and no quotes

# ❌ The * causes problems
branches:
  - feature/*                    # May fail

# ✅ With quotes
branches:
  - "feature/*"

Error 7: on without correct indentation

# ❌ Badly formatted trigger
on:
push:                            # Missing indentation
  branches: [main]

# ✅ Correct
on:
  push:
    branches: [main]

How to validate your YAML

Option 1: VS Code + YAML extension

Install Red Hat's "YAML" extension in VS Code/Cursor. It flags syntax errors in real time.

Option 2: Online linter

Paste your YAML into yamllint.com for a quick validation.

Option 3: Local command

# Install yamllint
pip install yamllint

# Validate your workflow
yamllint .github/workflows/ci.yml

Option 4: GitHub tells you

If you push invalid YAML, GitHub doesn't run the workflow. Check the Actions tab → you'll see an error indicator if the YAML has syntax problems.


Troubleshooting

"My workflow doesn't show up in Actions"

  1. Check that the file is in .github/workflows/ (with the dot)
  2. Check that the extension is .yml or .yaml
  3. Check that the YAML is valid (use yamllint)
  4. Check that the trigger matches your action (e.g. if you have on: push with branches: [main] but you pushed to develop, it doesn't run)

"Error: Invalid workflow file"

GitHub tells you this when the YAML is valid as YAML but isn't a valid workflow. Common causes:

  • The on key is missing (required)
  • The jobs key is missing (required)
  • A job doesn't have runs-on
  • A step has neither run nor uses

"Expected __, found __"

This parsing error indicates a problem with indentation or structure. The position it points to (line X, column Y) is where YAML found something unexpected. Check the indentation of the previous lines.


Exercises

Exercise 1: Fix the YAML

This workflow has 3 errors. Find them and fix them:

name: CI
on: push
jobs:
  test:
  runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: 3.10
      - run: pip install -r requirements.txt
       - run: pytest tests/
See solution

Error 1: runs-on isn't indented under test Error 2: python-version: 3.10 without quotes → YAML reads it as 3.1 Error 3: The last - run has an extra space of indentation

name: CI
on: push
jobs:
  test:
    runs-on: ubuntu-latest        # Fix 1: indented under test
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.10"  # Fix 2: quotes
      - run: pip install -r requirements.txt
      - run: pytest tests/        # Fix 3: correct indentation

Exercise 2: Write from scratch

Write a YAML workflow that:

  1. Is called "Lint Check"
  2. Runs on push to main
  3. Has a job called "lint"
  4. Runs on ubuntu-latest
  5. Does a checkout, installs ruff, and runs ruff check src/
See solution
name: Lint Check
on:
  push:
    branches: [main]
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      - name: Install ruff
        run: pip install ruff
      - name: Run linting
        run: ruff check src/

Exercise 3: Interpret the workflow

What does this workflow do? Describe each part:

name: Health Check
on:
  schedule:
    - cron: "0 8 * * 1"
  workflow_dispatch:
env:
  APP_URL: "https://my-ai-app.com"
jobs:
  check:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - name: Health check
        run: |
          response=$(curl -s -o /dev/null -w "%{http_code}" $APP_URL/health)
          if [ "$response" != "200" ]; then
            echo "Health check failed with status $response"
            exit 1
          fi
          echo "Health check passed"
See solution
  • Name: "Health Check"
  • Triggers: It runs every Monday at 8am UTC (cron: "0 8 * * 1") AND it can be run manually (workflow_dispatch)
  • Global variable: APP_URL points to the app in production
  • Job "check": Runs on ubuntu-latest with a 5-minute timeout
  • Step: Makes an HTTP request to the app's /health endpoint. If the status code isn't 200, the workflow fails. If it is 200, it reports success.

Real use: Automated weekly monitoring. If the app goes down over a weekend, this check detects it Monday at 8am.

Exercise 4: Multi-line vs single-line

Rewrite these steps using the pipe (|) for multi-line:

steps:
  - run: pip install -r requirements.txt
  - run: pip install pytest
  - run: pytest tests/ -v
See solution
steps:
  - name: Install and test
    run: |
      pip install -r requirements.txt
      pip install pytest
      pytest tests/ -v

Note: Combining into a single step with | reduces the number of steps (less UI clutter in Actions), but if pip install fails, the error message is less granular than with separate steps. Trade-off: debugging granularity vs workflow cleanliness.

Recommendation: Separate steps for logically distinct actions (install vs test), multi-line for related actions (multiple installs together).

Exercise 5: Environment variables

Write a workflow that defines a PYTHON_VERSION variable at the global level and uses it in the setup-python step:

See solution
name: CI
on: push
env:
  PYTHON_VERSION: "3.12"
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}
      - run: python --version

Advantage: If you need to change the Python version, you do it in a single place (env.PYTHON_VERSION) and all the jobs that use it get updated.


Summary

  • YAML uses indentation with spaces (never tabs), 2 spaces per level
  • Key-value pairs are the basis: key: value (space after the :)
  • Nested maps are created with indentation (like dicts in Python)
  • Lists use a dash: - item
  • Strings with versions need quotes: python-version: "3.12"
  • Multi-line uses the pipe (|) to preserve line breaks
  • Expressions ${{ }} access GitHub's context (secrets, variables, metadata)
  • Validate your YAML with the VS Code extension, yamllint, or GitHub's feedback

Additional resources

  1. YAML Syntax for GitHub Actions - Complete official reference
  2. YAML Specification 1.2 - Official language spec
  3. YAML Lint - Online validator
  4. Learn YAML in 5 minutes - Quick tutorial
  5. VS Code YAML Extension - Extension for in-editor validation
  6. GitHub Actions Expressions - Reference for ${{ }} expressions