Understanding GitHub Actions Workflow Files (YAML Explained in Detail)

Understanding GitHub Actions Workflow Files (YAML Explained in Detail)

A GitHub Actions workflow is a YAML file in .github/workflows/. It has three parts that matter: on (what triggers it), jobs (what runs, each on its own machine) and steps (the commands and actions inside each job). Everything else, such as permissions, environment variables, matrices and conditions, tunes how those three behave.

This guide walks through every top-level key with working examples, all validated with actionlint. Part of our complete Git and GitHub Actions guide. Want a ready-made test pipeline? See GitHub Actions for running unit tests.


A Complete Workflow, Annotated

name: CI

on:
  push:
    branches: [main]
  pull_request:
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

env:
  NODE_VERSION: "24"

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm test

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v7
      - run: ./deploy.sh
        env:
          API_TOKEN: ${{ secrets.API_TOKEN }}

Reading it top to bottom: it runs on pushes to main, on every pull request, and manually from the Actions tab. The token is read-only. A new push cancels an older run on the same branch. The test job runs first; deploy waits for it (needs), only runs for pushes to main (if), and uses a protected production environment that can hold its own secrets and require approval.


Top-Level Keys

KeyWhat it does
nameDisplay name in the Actions tab
onEvents that trigger the workflow
permissionsScopes for the automatic GITHUB_TOKEN; set the minimum you need
envEnvironment variables available to every job
concurrencyLimits parallel runs; cancel-in-progress cancels superseded ones
defaultsDefault shell or working-directory for run steps
jobsThe work itself

on: Triggers

on:
  push:
    branches: [main, "release/**"]
    paths: ["src/**", "package.json"]
  pull_request:
    types: [opened, synchronize, reopened]
  schedule:
    - cron: '30 6 * * 1-5'
  workflow_dispatch:
    inputs:
      environment:
        type: choice
        options: [staging, production]
        default: staging
  • branches and paths filters skip runs that do not matter, such as docs-only changes.
  • schedule uses cron syntax in UTC. The example runs at 06:30 UTC on weekdays.
  • workflow_dispatch adds a “Run workflow” button; inputs become form fields you read with ${{ inputs.environment }}.
  • Other common events: release, workflow_call (reusable workflows), workflow_run (after another workflow finishes) and merge_group (merge queues).

jobs: Runners, Dependencies and Conditions

Job keyPurpose
runs-onRunner label: ubuntu-latest, windows-latest, macos-latest, ARM runners like ubuntu-24.04-arm, or your self-hosted labels
needsJobs that must succeed first. Jobs without needs run in parallel
ifRun the job only when an expression is true
timeout-minutesKill a hung job early (the default is 360)
strategy.matrixRun the same job across versions or operating systems
environmentDeployment target with its own secrets, protection rules and approvals
outputsValues later jobs can read through needs
servicesSidecar containers such as a Postgres database for tests
permissionsOverride the token scopes for this job only

Matrix builds

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node: [22, 24]
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci && npm test

This runs six jobs in parallel (3 operating systems × 2 Node versions). fail-fast: false lets the others finish when one fails so you can see exactly which combinations break.

Passing data between jobs

jobs:
  version:
    runs-on: ubuntu-latest
    outputs:
      tag: ${{ steps.get.outputs.tag }}
    steps:
      - id: get
        run: echo "tag=v$(date +%Y.%m.%d)" >> "$GITHUB_OUTPUT"

  build:
    needs: version
    runs-on: ubuntu-latest
    steps:
      - run: echo "Building ${{ needs.version.outputs.tag }}"

A step writes name=value to the $GITHUB_OUTPUT file, the job exposes it under outputs, and a dependent job reads it through needs. For files rather than short values, use actions/upload-artifact and actions/download-artifact.


steps: uses vs run

  • uses runs a published action, pinned to a version: actions/checkout@v7. For third-party actions, pin to a full commit SHA for supply-chain safety.
  • run runs shell commands. Use | for multi-line scripts.
  • with passes inputs to an action; env sets variables for one step.
  • id names a step so you can read its outputs; if skips it conditionally, for example if: failure() to upload logs only when something broke.

Variables, Secrets and Expressions

SyntaxUse it for
${{ secrets.NAME }}Sensitive values; masked in logs
${{ vars.NAME }}Non-sensitive configuration (repository or organization variables)
${{ env.NAME }}Workflow, job or step env values inside expressions
$NAME in a run scriptThe same env values, read by the shell
${{ github.ref }}, ${{ github.sha }}Context about the triggering event
${{ matrix.node }}, ${{ inputs.x }}Matrix values and manual/reusable inputs

Pass untrusted values like pull request titles through env rather than inlining ${{ github.event.pull_request.title }} directly in a run script; inlining is a classic script-injection hole. More in GitHub Actions security best practices.


Reusable Workflows

Put shared pipelines in one repository and call them from others. The called workflow declares on: workflow_call; the caller uses it as a job:

jobs:
  call-tests:
    uses: my-org/shared-workflows/.github/workflows/node-tests.yml@main
    with:
      node-version: "24"
    secrets: inherit

Common YAML Mistakes

  • Indentation: YAML uses spaces only. A step indented one level too far silently becomes part of the previous step.
  • Unquoted versions: python-version: 3.10 is read as the number 3.1. Quote it: "3.10".
  • Wrong folder: the file must be in .github/workflows/ on the branch that triggers it, with a .yml or .yaml extension.
  • Missing permissions: if a step fails with 403, the job likely needs e.g. contents: write or id-token: write.
  • Catch it early: run actionlint locally or in CI; it catches syntax errors, bad expressions and unknown keys before you push.

Frequently Asked Questions

Where do GitHub Actions workflow files go?

In the .github/workflows directory at the root of your repository, as .yml or .yaml files.

What triggers a GitHub Actions workflow?

The on key: events such as push, pull_request, schedule (cron), workflow_dispatch (manual) or workflow_call (reusable).

What is the difference between a job and a step?

A job runs on its own runner and its steps share that machine; separate jobs run in parallel by default and use needs to depend on each other.

How do I pass data between jobs in GitHub Actions?

Use job outputs combined with needs, or upload and download artifacts for files.

You Might Also Like

Related guides