Overview
A workflow file declares triggers, jobs, and steps that run on managed runners. The rules below cover file layout, the action majors current as of 2026-10-01, caching, token scope, concurrency, injection and pinning, and reusable workflows. Branching and merge rules are in github.
Split workflows by trigger
Give each file under .github/workflows/ one responsibility: ci.yml on pull_request, deploy.yml on push to main, release.yml on tag or dispatch. When YAML repeats across jobs, extract a composite action in .github/actions/<name>/action.yml or a reusable workflow.
Use current action majors and check before copying
| Action | Major as of 2026-10-01 |
|---|---|
actions/checkout | v7 |
actions/setup-node | v7 |
actions/cache | v6 |
actions/upload-artifact | v7 |
actions/upload-pages-artifact | v5 |
actions/deploy-pages | v5 |
aws-actions/configure-aws-credentials | v6 |
Majors move often, so confirm on each action’s releases page before pasting an old snippet. Source: the releases pages of each repository.
Run jobs in parallel and steps in sequence
Each job gets a fresh runner; steps in a job share the filesystem and environment. Use needs: to order jobs and if: always() for a reporting job that must run after a failure.
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run lint
test:
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm testCache with setup-node first
cache: npm on setup-node caches the npm store keyed on the lockfile. Use actions/cache for anything else, and hash the lockfile into the key.
- uses: actions/cache@v6
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('requirements.txt') }}
restore-keys: pip-${{ runner.os }}-restore-keys is a prefix fallback, so a partial cache beats none.
Multiply jobs with a matrix
strategy:
fail-fast: false
matrix:
node: [22, 24]
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}Set fail-fast: false to see every failing leg in one run. Use matrix.include for one-off combinations and matrix.exclude to drop invalid ones.
Scope GITHUB_TOKEN to the job
New repositories and organizations default the token to read-only for contents and packages, but the default is a repository and organization setting, so declare permissions explicitly. Set the minimum at the top of the workflow and widen per job.
permissions:
contents: readA Pages deploy job adds pages: write and id-token: write; a release job adds contents: write. Credentials beyond the token go in secrets; see github-secrets.
Cancel stale runs
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: trueUse cancel-in-progress: true for CI so a new push replaces the old run. For production deploys use false so a running deploy finishes before the next starts.
Harden against injection and tag moves
- Pass untrusted context (
github.event.pull_request.title,github.head_ref, issue bodies) throughenv:and reference it as a shell variable. Interpolating${{ ... }}intorun:lets the value execute as code. - Pin third-party actions to a full-length commit SHA; a tag can be moved, a SHA cannot. GitHub’s security hardening guide recommends this for exactly that reason.
- Avoid
pull_request_targetunless required, and never check out untrusted pull request code under it.
Share workflows with workflow_call
# .github/workflows/build.yml
on:
workflow_call:
inputs:
node-version: { required: true, type: string }
secrets:
NPM_TOKEN: { required: true }Callers reference it as uses: org/repo/.github/workflows/build.yml@v1; pin a tag or SHA, not @main. Secrets are not passed to reusable workflows automatically, so pass them by name or with secrets: inherit. Pair the shared workflow with required status checks from github-branch-protection.