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/.yamlfiles.
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
| Section | Required | What it defines |
|---|---|---|
name | No (recommended) | Name visible in the UI |
on | Yes | When the workflow runs |
env | No | Global environment variables |
jobs | Yes | The 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"
- Check that the file is in
.github/workflows/(with the dot) - Check that the extension is
.ymlor.yaml - Check that the YAML is valid (use yamllint)
- Check that the trigger matches your action (e.g. if you have
on: pushwithbranches: [main]but you pushed todevelop, 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
onkey is missing (required) - The
jobskey is missing (required) - A job doesn't have
runs-on - A step has neither
runnoruses
"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:
- Is called "Lint Check"
- Runs on push to main
- Has a job called "lint"
- Runs on ubuntu-latest
- 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_URLpoints 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
/healthendpoint. 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
- YAML Syntax for GitHub Actions - Complete official reference
- YAML Specification 1.2 - Official language spec
- YAML Lint - Online validator
- Learn YAML in 5 minutes - Quick tutorial
- VS Code YAML Extension - Extension for in-editor validation
- GitHub Actions Expressions - Reference for
${{ }}expressions