
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
| Key | What it does |
|---|---|
name | Display name in the Actions tab |
on | Events that trigger the workflow |
permissions | Scopes for the automatic GITHUB_TOKEN; set the minimum you need |
env | Environment variables available to every job |
concurrency | Limits parallel runs; cancel-in-progress cancels superseded ones |
defaults | Default shell or working-directory for run steps |
jobs | The 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
branchesandpathsfilters skip runs that do not matter, such as docs-only changes.scheduleuses cron syntax in UTC. The example runs at 06:30 UTC on weekdays.workflow_dispatchadds a “Run workflow” button;inputsbecome form fields you read with${{ inputs.environment }}.- Other common events:
release,workflow_call(reusable workflows),workflow_run(after another workflow finishes) andmerge_group(merge queues).
jobs: Runners, Dependencies and Conditions
| Job key | Purpose |
|---|---|
runs-on | Runner label: ubuntu-latest, windows-latest, macos-latest, ARM runners like ubuntu-24.04-arm, or your self-hosted labels |
needs | Jobs that must succeed first. Jobs without needs run in parallel |
if | Run the job only when an expression is true |
timeout-minutes | Kill a hung job early (the default is 360) |
strategy.matrix | Run the same job across versions or operating systems |
environment | Deployment target with its own secrets, protection rules and approvals |
outputs | Values later jobs can read through needs |
services | Sidecar containers such as a Postgres database for tests |
permissions | Override 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
usesruns a published action, pinned to a version:actions/checkout@v7. For third-party actions, pin to a full commit SHA for supply-chain safety.runruns shell commands. Use|for multi-line scripts.withpasses inputs to an action;envsets variables for one step.idnames a step so you can read its outputs;ifskips it conditionally, for exampleif: failure()to upload logs only when something broke.
Variables, Secrets and Expressions
| Syntax | Use 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 script | The 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.10is 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.ymlor.yamlextension. - Missing permissions: if a step fails with 403, the job likely needs e.g.
contents: writeorid-token: write. - Catch it early: run
actionlintlocally or in CI; it catches syntax errors, bad expressions and unknown keys before you push.
Frequently Asked Questions
In the .github/workflows directory at the root of your repository, as .yml or .yaml files.
The on key: events such as push, pull_request, schedule (cron), workflow_dispatch (manual) or workflow_call (reusable).
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.
Use job outputs combined with needs, or upload and download artifacts for files.
You Might Also Like
- 👉 Day 1: Git Fundamentals
- 👉 Day 2: Basic Git Workflow
- 👉 Day 3: Branching and Merging
- 👉 Day 4: Remote Repositories
- 👉 Day 5: Advanced Git Operations
- 👉 Day 6: Git Workflows and Best Practices
- 👉 Day 7: Troubleshooting and Expert Techniques
- 👉 Advanced Git Commands You Need to Master (With Examples)
- 👉 Git Best Practices for Branching and Approvals
- 👉 GitHub CODEOWNERS & Permissions: Best Practices with Teams
- 👉 Git Hooks Explained: Automate Your Workflow with Examples
- 👉 Git Tags and Releases Best Practices: A Complete Guide
- 👉 Building and Publishing Docker Images for AWS, Azure, and GCP