Skip to content

GitHub Actions YAML Syntax Explained

Lesson 3 of 11Beginner12 min readGitHub Actions & CI/CD · Actions FundamentalsVerified: GitHub Actions workflow syntax documentation, August 2026

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.

YAML has exactly three building blocks, and workflows are made entirely of them.

Mappings are key-value pairs:

name: CI
runs-on: ubuntu-latest

Sequences are ordered lists, written with a leading dash:

steps:
- uses: actions/checkout@v7
- run: make test

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

Reading a workflow is largely a matter of tracking which of the three you are inside.

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 steps

Three 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 one

The first is easier to read in a nested structure and is what almost all workflows use.

YAML guesses types for unquoted scalars. Most of the time that is convenient. Three cases where it is not:

python-version: 3.10 # the NUMBER 3.1
python-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"]

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".

Leading or embedded :, #, *, &, {, [ and % all mean something to YAML:

name: Build: Release # invalid — the second colon
name: "Build: Release" # fine
run: echo #1 done # `#1 done` is a comment
run: "echo #1 done" # fine

A colon followed by a space is what separates a key from a value, so any value containing one needs quoting.

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 newline
run: | # keep one final newline (default)
run: |+ # keep all trailing newlines

This rarely matters for shell commands and matters a great deal when the value is compared as a string or written to a file.

A workflow file’s root mapping has a small, fixed set of valid keys.

KeyPurpose
nameDisplay name for the workflow
run-nameDisplay name for each run, supports expressions
onWhich events trigger it — required
permissionsDefault GITHUB_TOKEN scope for all jobs
envEnvironment variables available to all jobs
defaultsDefault settings for all run steps
concurrencyLimit simultaneous runs
jobsThe jobs — required

Only on and jobs are required. Everything else is optional and most workflows use three or four.

name: CI
run-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 test

run-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 start with # and run to end of line:

# Runs on every pull request targeting main
on:
pull_request:
branches: [main] # only main

YAML also has anchors and aliases for reuse:

defaults: &defaults
runs-on: ubuntu-latest
jobs:
test:
<<: *defaults # merge key

These 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.

${{ }} 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 colon
run: echo "${{ github.repository }}: building" # quoted, because of the colon

Expression 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.

The fastest feedback loop is not pushing and waiting.

Terminal window
python3 -c "import yaml,sys; yaml.safe_load(open(sys.argv[1]))" .github/workflows/ci.yml

What 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.

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.

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@a3f8c21b7e2d94ff08c6903cd8c0fde910a37f88

That 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.

Because workflows are files, ordinary tooling applies — which is genuinely useful when a pipeline behaves differently than expected on two branches.

Terminal window
# 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.

Everything from this lesson in one file, as a shape to start from:

name: CI
run-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.xml

Every 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.

  1. Write a workflow with python-version: 3.10 unquoted, run it, and read what version was installed.
  2. Quote it and compare.
  3. Introduce a tab character and observe the parse error.
  4. Change a run: | block to run: > and read the resulting command in the log.
  5. Add run-name: with an expression and confirm it appears in gh run list.
  6. 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.

The messages you will actually see, and their usual cause:

MessageUsually 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 allThe 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.

  • Workflows are mappings, sequences and scalars; nothing else.
  • Indentation is structural, and tabs are invalid.
  • 3.10 unquoted is 3.1 — quote every version number.
  • | preserves newlines and > folds them; run blocks want |.
  • Only on and jobs are required at the top level.
  • on is 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.

YAML has a compact “flow” style that looks like JSON, and workflows use it constantly for short values.

branches: [main, develop]
branches:
- main
- develop

Both 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: pip

Because 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.

Beyond on, a few values behave unexpectedly unquoted.

WrittenParsed asQuote it?
yes, no, on, offBooleansYes, if you mean the word
null, ~NullYes, if you mean the text
1.0The number 1Yes, for a version
08An error, or 8Yes — leading zeros suggest octal
2026-08-25A date objectYes, if you want a string
*An alias indicatorYes, always
@v7FineNo — @ 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.

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 hyphen

That 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.

Three levels, increasing in usefulness:

Terminal window
# 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 this
gh workflow run ci.yml && gh run watch

actionlint 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.

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.

Terminal window
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"
done

Adding 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.

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.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

The CI starter template — least-privilege permissions, pinned actions, correct checkout — is in the Professional Toolkit.