All posts

GitHub Actions YAML Without Memorizing the Schema

July 26, 2026 · DevTools

github-actions
ci-cd
yaml
devops
developer-tools

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:

TriggerFires when…Minimal example
pushA commit is pushed to the repoon: push
pull_requestA PR is opened, synced, or reopenedon: pull_request
scheduleA cron schedule firessee below
workflow_dispatchA user clicks "Run workflow" in the UIon: workflow_dispatch
workflow_callAnother workflow calls this one (reusable workflow)on: workflow_call
releaseA GitHub release is publishedon: release
issuesAn issue is opened, edited, closed, etc.on: issues
watchA star is addedon: 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 are windows-latest, macos-latest, or a specific version like ubuntu-22.04. Self-hosted runners use runs-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 with needs: [test, lint]
  • actions/setup-node@v4 with cache: "npm" is the modern way to cache node_modules between 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:

  1. Pin action versions. actions/checkout@v4 is safer than actions/checkout@main. The v4 tag is a major version; the full SHA is even more locked.
  2. Use name: for non-obvious steps. It becomes the heading in the GitHub Actions UI and makes failures easier to read.
  3. 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) or 0 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/checkout not pinned to a version@main will silently break when the action's API changes
  • Missing permissions: block — the default GITHUB_TOKEN scope is read-only for contents; 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