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

ActionMajor as of 2026-10-01
actions/checkoutv7
actions/setup-nodev7
actions/cachev6
actions/upload-artifactv7
actions/upload-pages-artifactv5
actions/deploy-pagesv5
aws-actions/configure-aws-credentialsv6

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 test

Cache 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: read

A 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: true

Use 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) through env: and reference it as a shell variable. Interpolating ${{ ... }} into run: 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_target unless 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.