GitHub Actions YAML: a validation checklist
The mistakes that only surface after a push: indentation, quoted booleans, cron in UTC, expression syntax, and unpinned actions. Catch them before CI does.
A workflow file has an unusual feedback loop: you cannot run it locally, so every mistake costs a commit, a push, a queue wait, and a red X. Most of those mistakes are visible in the file before the push. This is the list we check, in the order the failures usually appear.
1. It is valid YAML
Everything else depends on this. The two classic failures are a single misaligned key — one space too many under steps: — and a tab character, which YAML forbids for indentation and which most editors insert silently. Both produce bad indentation of a mapping entry or tab characters must not be used in indentation, and both are found in seconds by a validator.
Paste the file into YAML Validator before every push. It is faster than the round trip and it reports the line.
2. Booleans and versions are quoted
YAML 1.1, which the Actions parser follows in the ways that matter, reads on, off, yes, and no as booleans and reads 3.10 as the number 3.1. The first bites the on: key itself — which works, because GitHub special-cases it — and any with: input that is meant to be the string "yes". The second bites language version matrices:
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11"]
Without the quotes, 3.10 becomes 3.1, the setup action installs Python 3.1, and the failure message mentions a version you never wrote.
3. The cron schedule means what you think
schedule: uses standard five-field cron, evaluated in UTC, with the additional rule that GitHub runs scheduled jobs on a best-effort basis and may delay them under load. Two consequences: a schedule written for 9 a.m. local time will fire at the wrong hour for most of the world, and a * * * * * schedule will not run every minute and will get the workflow throttled.
The day-of-week and day-of-month fields are ORed when both are set, which surprises everyone once. Paste the expression into Cron Parser to read it back as a sentence, and keep the cron syntax cheat sheet open for the field ranges.
4. Expressions are inside ${{ }} — and only where needed
if: github.ref == 'refs/heads/main' works without the braces because if: is always an expression. run: echo ${{ secrets.TOKEN }} needs them because run: is a string. Mixing the two — if: ${{ ... }} is harmless, run: github.ref is a literal — is the source of a large share of "it ran but did nothing" workflows.
Two more rules inside the braces: single quotes for strings ('main', not "main"), and == for comparison. Double quotes inside an expression produce a syntax error that is only reported when the job starts.
5. Secrets never reach a log
Values from secrets.* are masked in logs, but only when they appear verbatim. A secret that is base64-encoded, split, or interpolated into a URL is not recognised by the masker and prints in full. Two habits prevent it: pass secrets as environment variables (env: TOKEN: ${{ secrets.TOKEN }}) rather than into run: strings, and never echo an environment variable that came from a secret, even for debugging.
GITHUB_TOKEN deserves a permissions: block that grants only what the job uses. The default is broader than most jobs need.
6. Actions are pinned
uses: actions/checkout@v4 floats: the v4 tag moves whenever a new v4.x is published, and a compromised or simply broken release reaches your pipeline automatically. Pin to a full commit SHA with the version in a comment:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
Dependabot can keep the SHA current. A tag cannot be trusted to point where it pointed yesterday.
7. The shape matches what GitHub expects
Beyond syntax, the file has to satisfy the workflow schema: every job needs runs-on, every step needs uses or run but not both, needs: must name jobs that exist, and on: must list events GitHub recognises. GitHub Actions Validator checks the structure against that schema and reports the path of the problem, which is the same information the "workflow file is invalid" banner gives you — except before the push.
A minimal checklist to keep in the PR template
- Valid YAML, no tabs, consistent indentation
- Versions and yes/no/on/off values quoted
- Cron read back as a sentence; UTC understood
${{ }}only in string contexts; single quotes inside- Secrets passed via
env:, never echoed;permissions:set - Every
uses:pinned to a SHA - Structure validated against the schema
Seven items, two minutes, one fewer red X. The Git commands for the resulting branch and PR are on the Git cheat sheet.