
8 min read
Understand YAML under 10 minutes
YAML · CICD · CI · CT · Toolchain · DevOps
A dense, honest pass over YAML as the language of pipelines: structure, types, gotchas, and the patterns that show up in GitHub Actions, GitLab CI, and Jenkins-adjacent configs, so continuous testing YAML stops looking like magic.
What YAML is (and is not)
YAML (YAML Ain’t Markup Language) is a data serialization format: nested maps, lists, and scalars that humans can edit and tools can parse into objects.
| It is | It is not |
|---|---|
| The usual surface for CI “workflow as code” | A programming language (no real loops/functions, only what the runner implements) |
| Indentation-sensitive structure | Free-form prose (one wrong space can change meaning) |
| A tree of keys → values | Proof that tests ran (only the steps and exit codes prove that) |
Mental model: every workflow file is a tree. Indentation is the tree. Colons introduce keys. Dashes introduce list items. Quotes protect strings that would otherwise look like booleans or numbers.
workflow (map)
├── name: string
├── on: map | list | string
└── jobs: map
└── build: map
├── runs-on: string
└── steps: list
├── map (uses: …)
└── map (run: …)
If you can draw that tree for a file, you can extend it.
Ten minutes of syntax that actually matters
1. Maps (objects)
Key, colon, value. Nested maps indent two spaces by convention (some tools accept more; never mix tabs).
job:
name: host-tests
timeout-minutes: 15
2. Lists (sequences)
Each item starts with - at the same indent level.
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run suite
run: pytest -q
A list of plain strings:
tags:
- Validation
- CI
- CT
3. Scalars: strings, numbers, booleans, null
count: 3
enabled: true
note: plain string
empty: null
Trap: unquoted yes, no, on, off, true, false are often booleans. Branch names and job ids that look like words should be quoted when in doubt:
# Safer when the value is a label, not a flag
ref: "on"
env_name: "no"
4. Multiline strings: | vs >
| Form | Meaning | Use |
|---|---|---|
| | Literal block: keep newlines | Shell scripts in run: |
> | Folded block: newlines → spaces (mostly) | Long prose descriptions |
|- / >- | Strip final newline | When trailing newline must not exist |
run: |
set -euo pipefail
cmake --build build
ctest --output-on-failure
For CI, prefer | for anything a shell will execute. Folded > is easy to misread when debugging.
5. Comments
# Full-line comment
timeout-minutes: 15 # end-of-line comment
Comments are for humans. They do not run. Do not put secrets in comments “temporarily.”
6. Anchors and aliases (optional but powerful)
Reuse a subtree without copy-paste:
x-default-runner: &default_runner
runs-on: ubuntu-latest
jobs:
unit:
<<: *default_runner
steps:
- run: echo unit
integration:
<<: *default_runner
steps:
- run: echo integration
Not every CI product supports every merge key the same way. When in doubt, duplicate the small block, clarity beats cleverness in a green suite.
7. What YAML deliberately does not do
- No native
${{ }}logic — that is GitHub Actions expression syntax inside strings the platform evaluates. - No native
include:semantics — GitLab and others define include/extends; YAML only holds the keys. - No guarantee two files that “look similar” mean the same thing on Jenkins vs GHA vs Azure DevOps.
Rule: learn YAML structure once; learn product schema per platform.
The CI-shaped tree (same bones, different labels)
Most “advanced” workflows are still: when → which jobs → which steps → what artifacts/secrets.
| Idea | GitHub Actions | GitLab CI | Jenkins (declarative / JCasC-ish) |
|---|---|---|---|
| Trigger | on: | rules: / branch pipelines | triggers { } / multibranch |
| Job graph | jobs: + needs: | stages: + job names | stages { } / parallel |
| Runner | runs-on: | tags: / runners | agent { } |
| Steps | steps: (run / uses) | script: | steps { sh '…' } |
| Env | env: | variables: | environment { } / credentials |
| Matrix | strategy.matrix | parallel: matrix | matrix / axis plugins |
| Artifacts | actions/upload-artifact | artifacts: | archiveArtifacts |
If you can answer those seven rows for a file, you can read it.
Minimal continuous testing shape (GitHub Actions flavor)
Honest portfolio pattern: build or install tools → run tests → fail the job on non-zero → keep a report when you have one.
name: ct-host
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install
run: |
python -m pip install -U pip
pip install -r requirements.txt
- name: Run host suite
run: |
set -euo pipefail
pytest --junitxml=reports/junit.xml
- name: Upload junit
if: always()
uses: actions/upload-artifact@v4
with:
name: junit
path: reports/junit.xml
Patterns that unlock “advanced” workflows
You do not need more YAML primitives. You need these composition patterns on top of the tree.
A. Multiple jobs and ordering
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: make firmware
- uses: actions/upload-artifact@v4
with:
name: image
path: build/app.bin
test:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: image
- run: ./scripts/smoke_host.sh
needs: is the dependency edge. Advanced DAGs are still jobs + needs (or stages).
B. Matrix builds (one job, many axes)
strategy:
fail-fast: false
matrix:
gcc: ["12", "13"]
build_type: [Debug, Release]
Use fail-fast: false when you want full evidence across the matrix instead of stopping at the first red cell, often better for validation-style reports.
C. Environment and secrets (structure only)
env:
CMAKE_BUILD_TYPE: Release
steps:
- name: Deploy report (example)
env:
TOKEN: ${{ secrets.REPORT_TOKEN }} # platform expression; value not in Git
run: ./scripts/publish_report.sh
YAML holds the name of the secret slot. The value must never be committed. That is process, not syntax.
D. Conditional steps
- name: Only on main
if: github.ref == 'refs/heads/main'
run: ./scripts/publish.sh
Condition syntax is product-specific. Structurally it is still “optional node in the step list.”
E. Reusable workflows / templates (platform features)
| Platform idea | What you are really doing |
|---|---|
workflow_call / reusable workflow | Calling another YAML tree with inputs |
GitLab include: / extends: | Merging maps from other files |
| Jenkins shared library | Code reuse outside pure YAML |
Learn the host’s include model after the base tree is comfortable.
F. Caching and services (still just keys)
services:
redis:
image: redis:7
ports:
- 6379:6379
A service block is a nested map under the job. Advanced ops are new keys, same indentation discipline.
Gotchas that burn CI time
| Gotcha | Symptom | Habit |
|---|---|---|
| Tab characters | Parser errors or “looks fine locally” | Editor: spaces only; show invisibles |
| Indent off by one | Key becomes a sibling instead of a child | Collapse/expand in an editor that shows structure |
| Unquoted booleans | on: true when you meant a string branch name | Quote ambiguous scalars |
| Colon in unquoted string | Parse error | Quote: "Board: lab-01" |
| CRLF vs LF | Rare weirdness on self-hosted Windows runners | Prefer LF in workflow files |
| Copy-pasted “works on my machine” secrets | Token rotation drama | Secret store only |
| Green without tests | Pipeline “passes” after compile only | Make CT an explicit step with non-zero on fail |
| Assuming YAML = CI | Beautiful file, never runs | Confirm triggers and branch protection |
Quick validate without pushing
- GitHub: paste into the workflow editor UI, or use
actionlintwhen installed. - yamllint: structure and style (does not know GHA schema).
- GitLab: CI Lint in the project UI.
Schema validation ≠ “tests are good.” It only means the tree is well-formed for that product.
A reading drill (use on any workflow)
Open a real .github/workflows/*.yml or .gitlab-ci.yml and answer out loud:
- When does this run? (
on/ rules / triggers) - How many jobs, and what depends on what?
- Where is build vs test (CT)?
- What is the fail condition (which command’s exit code)?
- What artifacts or logs survive a red run?
- Where would a secret be referenced—and is any value in Git?
- Is any path lab-only or self-hosted (labels/tags)?
If you can answer those seven, you have enough foundation to modify the file instead of treating it as magic.
From foundations to “any advanced” workflow
Advanced CI is usually one of these stacked on the same YAML tree:
| Goal | You will add… |
|---|---|
| Faster feedback | split jobs, caching, path filters |
| Broader evidence | matrix of compilers, boards, Python versions |
| Safer releases | environment gates, manual approval keys (product feature) |
| Embedded / lab | self-hosted runners, hardware labels, longer timeouts |
| Compliance hygiene | required checks, signed commits policy (org), static-analysis job |
| Reuse | reusable workflows, includes, shared templates |
None of that invents a new data format. It invents policy on top of maps and lists.
What this note deliberately skips
- Full GitHub Actions expression language reference
- Groovy / Jenkinsfile as a primary language (different surface; same pipeline ideas)
- Kubernetes manifests (also YAML; different schema)
- Claiming a production multi-org fleet
When you need depth, open one official schema doc for your host and map every new key back to: trigger, job, step, env, artifact.
Closing
YAML for CI is a tree with rules. Indentation is structure. Lists are steps and matrices. Strings that look like booleans need quotes. Multiline | is how honest shell blocks enter the file. Platforms then hang when, where, and with which secrets on that tree.
Ten minutes of structure is enough to stop being afraid of workflows. The rest of continuous testing is still the hard part: oracles, exit codes, and evidence you would trust in a review.
Was this page helpful?