Workflow files are YAML, and YAML is a format that mostly does what you expect until it does not.
This lesson covers the subset workflows need, and spends most of its length on the handful of
behaviours that produce genuinely confusing failures — a version number that becomes a different
number, a string that becomes a boolean, a key that some parsers read as true.
The three structures
Section titled “The three structures”YAML has exactly three building blocks, and workflows are made entirely of them.
Mappings are key-value pairs:
name: CIruns-on: ubuntu-latestSequences are ordered lists, written with a leading dash:
steps: - uses: actions/checkout@v7 - run: make testScalars are the values themselves — strings, numbers, booleans.
Everything else is these three nested. A workflow is a mapping whose jobs key holds a mapping of
job IDs, each of which holds a mapping containing a steps sequence of mappings.
jobs: # mapping test: # mapping (job ID → job) runs-on: ubuntu-latest steps: # sequence - name: Run tests # mapping run: make testReading a workflow is largely a matter of tracking which of the three you are inside.
Indentation
Section titled “Indentation”Indentation is structural, not cosmetic. It determines what belongs to what.
jobs: build: # a key inside jobs runs-on: ubuntu-latest # a key inside build steps: - run: echo one # an item in build's stepsThree rules, and one of them is absolute:
Spaces only. A tab character anywhere in a YAML file is invalid. Editors that insert tabs will produce a file GitHub rejects with a parse error that does not mention tabs.
Be consistent. Two spaces per level is conventional. What matters is that siblings align exactly.
Sequence items align with their key or are indented from it. Both of these are valid and mean the same thing:
steps: - run: echo one
steps:- run: echo oneThe first is easier to read in a nested structure and is what almost all workflows use.
Quoting, and when it matters
Section titled “Quoting, and when it matters”YAML guesses types for unquoted scalars. Most of the time that is convenient. Three cases where it is not:
Version numbers
Section titled “Version numbers”python-version: 3.10 # the NUMBER 3.1python-version: "3.10" # the STRING "3.10"3.10 parsed as a number is 3.1, because trailing zeros in a decimal are meaningless. Your workflow
then sets up Python 3.1, or more likely fails to find it.
This is the single most damaging YAML gotcha in GitHub Actions, because it appears in exactly the place people use it most — language version matrices — and the failure message talks about a version that does not exist rather than about quoting.
Quote every version number. Always.
strategy: matrix: python-version: ["3.11", "3.12", "3.13"] node-version: ["20", "22", "24"]Values that look like booleans
Section titled “Values that look like booleans”YAML 1.1 treats a surprising set of words as booleans: yes, no, on, off, true, false, and
capitalised variants.
env: DEPLOY: no # may become the boolean false DEPLOY: "no" # the string "no"If a value must reach a shell as text, quote it. Environment variable values are converted to strings
eventually, but the conversion of a boolean false is false, not no — which breaks a comparison
against "no".
Strings with special characters
Section titled “Strings with special characters”Leading or embedded :, #, *, &, {, [ and % all mean something to YAML:
name: Build: Release # invalid — the second colonname: "Build: Release" # fine
run: echo #1 done # `#1 done` is a commentrun: "echo #1 done" # fineA colon followed by a space is what separates a key from a value, so any value containing one needs quoting.
Multiline strings
Section titled “Multiline strings”Two forms, and the difference is whether newlines survive.
- run: | echo "first line" echo "second line"| is literal: newlines are preserved, so this is two commands. This is what you want for run:
blocks almost always.
description: > This long description is folded into one line.> is folded: newlines become spaces. Useful for prose in a description: field, and wrong for
shell commands — a folded run: block becomes one very long line, so echo one and echo two
become echo one echo two.
Both accept a chomping indicator controlling the trailing newline:
run: |- # strip the final newlinerun: | # keep one final newline (default)run: |+ # keep all trailing newlinesThis rarely matters for shell commands and matters a great deal when the value is compared as a string or written to a file.
The workflow’s top-level keys
Section titled “The workflow’s top-level keys”A workflow file’s root mapping has a small, fixed set of valid keys.
| Key | Purpose |
|---|---|
name | Display name for the workflow |
run-name | Display name for each run, supports expressions |
on | Which events trigger it — required |
permissions | Default GITHUB_TOKEN scope for all jobs |
env | Environment variables available to all jobs |
defaults | Default settings for all run steps |
concurrency | Limit simultaneous runs |
jobs | The jobs — required |
Only on and jobs are required. Everything else is optional and most workflows use three or four.
name: CIrun-name: CI for ${{ github.event.pull_request.title || github.ref_name }}
on: pull_request: branches: [main]
permissions: contents: read
env: LOG_LEVEL: info
defaults: run: shell: bash working-directory: ./app
concurrency: group: ci-${{ github.ref }} cancel-in-progress: true
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - run: make testrun-name is the least-known and genuinely useful: it names each run in the list, so a page of runs
says what each one was about rather than repeating the workflow name.
defaults.run applies shell and working-directory to every run step, which removes a great deal
of repetition in a monorepo where all commands happen in one subdirectory.
Comments and anchors
Section titled “Comments and anchors”Comments start with # and run to end of line:
# Runs on every pull request targeting mainon: pull_request: branches: [main] # only mainYAML also has anchors and aliases for reuse:
defaults: &defaults runs-on: ubuntu-latest
jobs: test: <<: *defaults # merge keyThese are valid YAML and GitHub Actions does not support them in workflow files. The parser rejects merge keys, and anchors do not behave as you would hope. Reuse in Actions is reusable workflows and composite actions, not YAML features.
This is worth knowing because anchors are the obvious answer to “these three jobs are identical” and they simply do not work here.
Expressions are not YAML
Section titled “Expressions are not YAML”${{ }} is GitHub’s expression syntax, evaluated before the YAML value reaches anything else.
- run: echo "Branch is ${{ github.ref_name }}"Two things follow, and both matter.
An expression containing a colon needs the value quoted, because YAML sees the colon first:
if: ${{ github.event_name == 'push' }} # fine — no colonrun: echo "${{ github.repository }}: building" # quoted, because of the colonExpression results are substituted as text into the command. They are not shell variables. That distinction is the root of the injection problem covered in Contexts and Expressions — a pull request title containing shell metacharacters becomes shell syntax, not a string.
Validating before you push
Section titled “Validating before you push”The fastest feedback loop is not pushing and waiting.
python3 -c "import yaml,sys; yaml.safe_load(open(sys.argv[1]))" .github/workflows/ci.ymlWhat it doesParses a workflow file as YAML and reports syntax errors, without needing GitHub.
Why we run itCatches indentation and quoting errors in a second rather than after a push, a run and a failed parse. It does not validate the workflow schema — only that the YAML is well-formed.
Expected resultSilence on success; a parse error with a line number otherwise.
For schema validation — checking that keys are keys Actions recognises — an editor extension with the
workflow schema is the practical answer. It flags runs_on instead of runs-on as you type, which a
YAML parser cannot.
actionlint is a dedicated linter that goes further, checking expression syntax, action references
and shell issues inside run blocks. For a repository with many workflows it is worth adding to CI.
Common mistakes
Section titled “Common mistakes”Unquoted version numbers. 3.10 becomes 3.1. Quote every version.
Tabs. Invalid YAML, and the error does not say so.
Sibling instead of child indentation. The reported line looks correct; check above it.
> for a run block. Folds your commands into one line.
Expecting YAML anchors to work. They do not; use reusable workflows or composite actions.
Unquoted values containing : or #. Parsed as structure or comment.
Trusting a local linter about on. It may be applying YAML 1.1 where GitHub is not.
Structuring a readable workflow
Section titled “Structuring a readable workflow”Beyond correctness, a few conventions make workflows readable to people who did not write them.
Order the top-level keys consistently. name, run-name, on, permissions, env,
concurrency, defaults, jobs. A reader looking for the trigger finds it in the same place every
time.
Name every step. The log shows the command otherwise, which is fine for npm ci and unreadable
for a twenty-line script.
Keep run: blocks short. More than about ten lines belongs in a script under scripts/, which is
testable locally and reviewable by people who do not read workflow YAML.
Comment the non-obvious. Not what a step does — that is visible — but why a condition exists or why an action is pinned to an unusual version.
# Pinned to 3.9.x: 4.x drops support for the legacy config format # we still use in services/legacy-api. Tracked in #412. - uses: some-org/some-action@a3f8c21b7e2d94ff08c6903cd8c0fde910a37f88That comment answers the question a future reader will actually have, which “installs the thing” does not.
Group related jobs by naming convention. build-linux, build-macos, test-unit,
test-integration sorts and reads better than linux, mac, unit, integration.
Comparing workflows
Section titled “Comparing workflows”Because workflows are files, ordinary tooling applies — which is genuinely useful when a pipeline behaves differently than expected on two branches.
# What changed in this workflow?git log --oneline -- .github/workflows/ci.yml
# How does it differ between branches?git diff main..my-branch -- .github/workflows/
# What does the default branch actually have?gh api "repos/OWNER/REPO/contents/.github/workflows/ci.yml?ref=main" \ -H "Accept: application/vnd.github.raw"That third command settles the most common workflow confusion: the file in your editor is not
necessarily the file that ran. Workflows execute from the ref that triggered them, except for
schedule, workflow_run and pull_request_target, which run the default branch version.
Diffing what you have against what actually ran is usually faster than reasoning about which rule applies.
A reference workflow
Section titled “A reference workflow”Everything from this lesson in one file, as a shape to start from:
name: CIrun-name: CI — ${{ github.event.pull_request.title || github.ref_name }}
on: pull_request: branches: [main] paths-ignore: ['**.md', 'docs/**'] push: branches: [main] merge_group: workflow_dispatch:
permissions: contents: read
env: FORCE_COLOR: "1"
concurrency: group: ci-${{ github.ref }} cancel-in-progress: true
defaults: run: shell: bash
jobs: test: name: Test (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest timeout-minutes: 15 strategy: fail-fast: false matrix: python-version: ["3.12", "3.13"] steps: - uses: actions/checkout@v7
- uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} cache: pip
- name: Install dependencies run: pip install -r requirements.txt
- name: Run tests run: pytest --junitxml=results.xml
- name: Upload results if: always() uses: actions/upload-artifact@v7 with: name: results-${{ matrix.python-version }} path: results.xmlEvery construct in it has appeared in this lesson: quoted versions, paths-ignore, concurrency with
cancellation, defaults, a matrix with fail-fast: false, an expression in a job name, and
if: always() on the upload so results survive a test failure.
Exercise
Section titled “Exercise”- Write a workflow with
python-version: 3.10unquoted, run it, and read what version was installed. - Quote it and compare.
- Introduce a tab character and observe the parse error.
- Change a
run: |block torun: >and read the resulting command in the log. - Add
run-name:with an expression and confirm it appears ingh run list. - Try a YAML anchor and confirm GitHub rejects it.
Step 1 is worth doing even though you know the answer — seeing Python 3.1 in a log is what makes the
rule stick.
Errors and what they mean
Section titled “Errors and what they mean”The messages you will actually see, and their usual cause:
| Message | Usually means |
|---|---|
| “Invalid workflow file … you have an error in your yaml syntax on line N” | Indentation, a tab, or an unquoted special character — check the lines above N |
| “Unexpected value ‘X’” | A valid key with a value of the wrong type or shape |
| “Unrecognized named-value” | A context that does not exist in that position |
| “Required property is missing: runs-on” | A job without a runner, often from a mis-indented key |
| “The workflow is not valid … unexpected symbol” | An expression problem — usually an unquoted value containing ! or : |
| Workflow does not appear at all | The file is not in .github/workflows/, or the YAML failed to parse |
The last row is the one that wastes the most time, because there is no message anywhere. A workflow whose YAML is invalid does not appear in the Actions tab at all, so it looks like the file was never committed.
gh workflow list --all is the check: a workflow present but broken is listed; one absent from the
list either does not exist or did not parse.
What you learned
Section titled “What you learned”- Workflows are mappings, sequences and scalars; nothing else.
- Indentation is structural, and tabs are invalid.
3.10unquoted is3.1— quote every version number.|preserves newlines and>folds them;runblocks want|.- Only
onandjobsare required at the top level. onis a YAML 1.1 boolean word, which is why local linters and GitHub can disagree.- YAML anchors are not supported; reuse is a platform feature, not a YAML one.
${{ }}is substituted as text before the shell sees it.
Flow style and JSON
Section titled “Flow style and JSON”YAML has a compact “flow” style that looks like JSON, and workflows use it constantly for short values.
branches: [main, develop]branches: - main - developBoth are identical. Flow style is conventional for short lists — branch filters, matrix values — and block style for anything longer.
Mappings work the same way:
with: { python-version: "3.13", cache: pip }
with: python-version: "3.13" cache: pipBecause JSON is valid YAML, a workflow can contain literal JSON where that is convenient — which is what makes dynamic matrices work:
strategy: matrix: ${{ fromJSON(needs.discover.outputs.matrix) }}The expression produces a JSON string, fromJSON parses it, and the result is a matrix definition.
Reserved and surprising values
Section titled “Reserved and surprising values”Beyond on, a few values behave unexpectedly unquoted.
| Written | Parsed as | Quote it? |
|---|---|---|
yes, no, on, off | Booleans | Yes, if you mean the word |
null, ~ | Null | Yes, if you mean the text |
1.0 | The number 1 | Yes, for a version |
08 | An error, or 8 | Yes — leading zeros suggest octal |
2026-08-25 | A date object | Yes, if you want a string |
* | An alias indicator | Yes, always |
@v7 | Fine | No — @ is only special at the start |
The date case catches people writing a version like 2026.08.25 — that one is fine — versus
2026-08-25, which some parsers turn into a timestamp.
Schema errors versus YAML errors
Section titled “Schema errors versus YAML errors”Two different failures with different messages.
A YAML error means the file cannot be parsed at all. GitHub reports “Invalid workflow file” with a line number, and the workflow does not appear in the Actions tab.
A schema error means the YAML parsed but the structure is not a valid workflow — an unknown key, a missing required field, a value of the wrong type.
jobs: build: runs_on: ubuntu-latest # underscore, not hyphenThat is valid YAML and an invalid workflow. The message names the unexpected key, and the fix is obvious once you read it — but a YAML linter will not catch it, because as YAML it is perfectly fine.
This is the argument for an editor extension carrying the workflow schema: it flags runs_on as you
type, which no YAML-only tool can.
Validating locally
Section titled “Validating locally”Three levels, increasing in usefulness:
# 1. Is it valid YAML?python3 -c "import yaml,sys; yaml.safe_load(open(sys.argv[1]))" .github/workflows/ci.yml
# 2. Is it a valid workflow? — editor extension with the schema, or:actionlint .github/workflows/ci.yml
# 3. Does it do what you meant? — only a real run answers thisgh workflow run ci.yml && gh run watchactionlint goes considerably further than schema validation: it checks expression syntax, verifies
that referenced contexts exist in that position, warns about shell issues inside run: blocks, and
flags untrusted-input interpolation. For a repository with several workflows it is worth running in CI
against the workflows themselves.
A habit worth forming
Section titled “A habit worth forming”Run the workflow through a YAML parser before pushing. It takes a second and catches the entire class of error that produces “Invalid workflow file” with an unhelpful line number.
for f in .github/workflows/*.yml; do python3 -c "import yaml,sys; yaml.safe_load(open(sys.argv[1]))" "$f" \ && echo "ok $f" || echo "FAIL $f"doneAdding that to a pre-commit hook, or to CI as a check on the workflows themselves, means a malformed workflow is caught by the person who wrote it rather than discovered when a run silently does not happen.
Summary
Section titled “Summary”Workflow YAML is three structures and a handful of traps. Quote version numbers, never use tabs, check
indentation above the reported error line, and use | rather than > for run blocks.
Beyond that, an editor extension carrying the workflow schema catches the errors a YAML parser cannot — a misspelled key is perfectly valid YAML and an invalid workflow.