GitHub Actions YAML Without Memorizing the Schema
July 26, 2026 · DevTools
You know you want CI to run tests on every pull request, build a container image on every merge to main, and ping Slack when the nightly job fails. You know it should be in .github/workflows/. You open a fresh YAML file, type name:, type on:, save, and discover that GitHub has silently ignored your workflow. The trigger list is empty. Nothing runs. You stare at the file for ten minutes, push a commit, nothing happens.
The cause is one of the most famous YAML traps in the industry: on is a YAML boolean keyword, and unquoted on: parses as the literal true.
You can dodge the trap entirely with the GitHub Actions YAML Generator. It emits workflow YAML with the on: key quoted correctly, in the right key order, with valid trigger syntax. Everything runs locally in your browser — the workflow isn't uploaded to GitHub until you commit it.
The shape of a workflow file
A GitHub Actions workflow is one YAML file under .github/workflows/, ending in .yml or .yaml. Four top-level keys cover almost everything you'll write:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- run: npm test
name — what shows up in the GitHub UI. Free-form string.
on — when the workflow runs. This is the key that trips people up.
jobs — what runs. A workflow has one or more jobs; each job runs on a fresh virtual machine (or container) and contains an ordered list of steps.
permissions (optional) — the GITHUB_TOKEN scopes granted to the workflow. Important for security; defaults are restrictive.
That's the whole skeleton. Anything else you see — env, defaults, concurrency, workflow_call — is optional and you can leave it alone until you need it.
The on: boolean trap
The most common GitHub Actions YAML error is also one of the easiest to fix. Here's the broken version:
on: push
In YAML 1.1 (which most parsers still default to), the word on is a boolean synonym for true (alongside yes, no, off, and the obvious true/false). The parser reads on: push as the boolean true with the value push — which is meaningless — and your trigger list is effectively empty.
GitHub's own docs acknowledge this and recommend one of two fixes:
"on": push # quote the key
on: "push" # or quote the value (works either way)
Quoting the key with double quotes is the most defensive option: it forces the parser to treat on as a string no matter which YAML version is in use.
The GitHub Actions YAML Generator always quotes on: correctly, so the trap doesn't appear in the output. If you're writing YAML by hand, make this the first thing you check whenever a workflow silently does nothing.
The common triggers
Eight triggers cover the vast majority of real workflows. Each is a key under on: with its own configuration:
| Trigger | Fires when… | Minimal example |
|---|---|---|
push | A commit is pushed to the repo | on: push |
pull_request | A PR is opened, synced, or reopened | on: pull_request |
schedule | A cron schedule fires | see below |
workflow_dispatch | A user clicks "Run workflow" in the UI | on: workflow_dispatch |
workflow_call | Another workflow calls this one (reusable workflow) | on: workflow_call |
release | A GitHub release is published | on: release |
issues | An issue is opened, edited, closed, etc. | on: issues |
watch | A star is added | on: watch |
The first three — push, pull_request, schedule — are what you'll reach for most often.
Push / pull_request with branch filters:
on:
push:
branches: [main, release/*]
paths:
- "src/**"
- "package.json"
pull_request:
branches: [main]
branches filters by branch name (with glob support); paths filters by changed files. The combination is useful when you want CI to run only when source files change, not on README-only commits.
Scheduled (cron):
on:
schedule:
- cron: "0 3 * * *"
Cron in GitHub Actions is the standard five-field Unix cron, interpreted in UTC. "Every day at 03:00 UTC" is 0 3 * * *. Be aware that scheduled runs are often delayed — GitHub doesn't promise exact timing — so don't use this for anything that needs second-level precision. The Cron Builder helps you compose the expression without counting fields.
Manual dispatch (UI button):
on:
workflow_dispatch:
inputs:
environment:
description: "Where to deploy"
required: true
default: "staging"
type: choice
options:
- staging
- production
workflow_dispatch adds a "Run workflow" button to the Actions tab. The inputs block defines parameters the user can fill in when they trigger — useful for deploy workflows where you want to pick the environment.
Multiple triggers in one workflow:
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
That's the full set for a typical CI workflow: run on every push and PR, and allow manual re-runs.
Jobs: the actual work
jobs: is a map of job-id to job-spec. Each job has its own runner, its own checkout, and its own environment:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- run: npm test
env:
CI: "true"
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- run: npm run lint
A few things to notice:
runs-on: ubuntu-latest— the runner image. Other common values arewindows-latest,macos-latest, or a specific version likeubuntu-22.04. Self-hosted runners useruns-on: self-hosted- Each job checks out its own copy of the code via
actions/checkout@v4. There's no shared working directory between jobs - The job id (
test,lint) becomes part of the workflow run UI and is referenced by other jobs withneeds: [test, lint] actions/setup-node@v4withcache: "npm"is the modern way to cachenode_modulesbetween runs; a 30-second install becomes a 3-second install
For multi-job workflows, needs: controls the dependency graph:
jobs:
test:
runs-on: ubuntu-latest
steps: [...]
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps: [...]
deploy waits for test to succeed, and only runs on pushes to main (not on PRs from forks).
Steps: uses vs run
A step is either an action (uses:) or a shell command (run:). Actions are reusable units — sometimes community-published, sometimes first-party — that someone else has packaged; run is a literal shell command on the runner.
steps:
- uses: actions/checkout@v4 # an action
- uses: actions/setup-node@v4 # another action
with: # action parameters
node-version: "20"
- run: npm ci # a shell command
- run: npm test # another shell command
- name: Run migrations # a labeled shell command
run: npm run migrate
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
Three tips for steps:
- Pin action versions.
actions/checkout@v4is safer thanactions/checkout@main. Thev4tag is a major version; the full SHA is even more locked. - Use
name:for non-obvious steps. It becomes the heading in the GitHub Actions UI and makes failures easier to read. - Secrets via
${{ secrets.X }}. Never hardcode credentials; reference secrets stored in the repo or org settings.
A complete before-and-after
You want CI to run on push and PR, plus a nightly schedule, plus a manual button. You also want a deploy job that only runs after tests pass on main.
The generator can emit this directly. The shape:
name: CI
"on":
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- run: npm test
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: ./scripts/deploy.sh
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
Note "on": (quoted) — this is the defensive form that survives YAML 1.1 and 1.2 parsers alike. The generator emits it this way automatically.
Common mistakes
- Forgetting to quote
on:— the workflow silently does nothing - Wrong cron timezone — GitHub's cron runs in UTC, not your local zone; an "8 AM Pacific" run is
0 16 * * *(PST) or0 15 * * *(PDT) - Trying to share state between jobs — each job gets a fresh runner; use artifacts (
actions/upload-artifact) or a cache to pass data actions/checkoutnot pinned to a version —@mainwill silently break when the action's API changes- Missing
permissions:block — the default GITHUB_TOKEN scope is read-only forcontents; explicit permissions are safer and required for some orgs - Secrets in PRs from forks — secrets are not available in workflows triggered by pull_request from forks, by design
Everything stays in your browser
The GitHub Actions YAML Generator builds the file from a form, entirely locally. No workflow is uploaded to any service; you copy the YAML out and commit it yourself.
Next steps
- Validate the YAML before committing: YAML Validator
- Compose the cron expression for scheduled triggers: Cron Builder
- A general guide to cron syntax: Cron Expressions Guide