A workflow that runs is a low bar — plenty of .github/workflows/*.yml files clear it while still burning CI minutes on stale runs, silently skipping cleanup steps after a failure, or duplicating the same ten lines across five files. None of what follows is exotic; it's the difference between a workflow someone wrote once to get tests running and one that's still cheap to run and easy to change a year later.
Cancel superseded runs with concurrency:
Push three commits to a PR in quick succession with no concurrency: block and you get three full CI runs, with only the last one's result actually mattering. A concurrency group cancels any still-running run in the same group the moment a new one starts:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: truegithub.ref makes the group unique per branch (or per PR), so pushes to different branches never cancel each other — only stale runs on the same branch do.
Cache dependencies instead of reinstalling every run
Reinstalling the same node_modules from scratch on every single run wastes real time on every single job. actions/setup-node (and the equivalent setup actions for other ecosystems) has caching built in — one line, no separate cache key to manage:
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"For anything without a dedicated setup action, actions/cache caches an arbitrary path against a key you control — commonly a hash of the lockfile, so the cache invalidates exactly when dependencies actually change.
Test a matrix, and decide deliberately about fail-fast
A strategy.matrix runs the same job once per combination — testing three Node versions is three extra lines, not three duplicated jobs:
strategy:
fail-fast: false
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}fail-fast defaults to true, which cancels every other matrix combination the moment one of them fails — good for saving CI time, bad when you actually want to know whether it's just Node 18 that's broken or all three. Set it to false when the answer matters more than the minutes.
Don't let a failure silently skip your cleanup step
By default, the moment one step in a job fails, every step after it is skipped and the job is marked failed — including a notification or cleanup step you wanted to run especially when something failed. Give that step an explicit condition:
- run: npm test
- if: failure()
run: ./notify-failure.sh
- if: always()
run: ./cleanup.shfailure() runs only if something earlier in the job failed; always() runs regardless of outcome. Without one of these, both steps above would simply never run on a failing build — exactly the case you most wanted them for.
Share data between jobs with artifacts, not assumptions
Each job runs on its own disposable machine, so a file one job creates doesn't exist for the next one — needs: only controls ordering, not filesystem access. Upload what the next job needs, then download it there:
# in the build job
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
# in the deploy job (needs: build)
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/Extract repeated steps into a composite action or reusable workflow
The same five-step "checkout, install, lint" block copy-pasted across six workflow files is six places to update the next time one of those steps changes. A composite action (a local action.yml under .github/actions/) turns repeated steps into one reusable step; a reusable workflow does the same for an entire job, callable from other workflows — even other repos:
# .github/workflows/deploy-reusable.yml
on:
workflow_call:
inputs:
environment:
required: true
type: string
secrets:
DEPLOY_TOKEN:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
env:
TOKEN: ${{ secrets.DEPLOY_TOKEN }}
# caller workflow
jobs:
deploy:
uses: ./.github/workflows/deploy-reusable.yml
with:
environment: production
secrets:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}Listing secrets explicitly on the caller (rather than secrets: inherit) means the reusable workflow only ever receives the one secret it actually declared needing — worth doing even though inherit is shorter to type.
Give the token only the permissions the job needs
With no permissions: block, a job gets the repository's default GITHUB_TOKEN permissions — which, depending on repo settings, can mean broad write access a typical test job never uses. Set a restrictive default at the top of the file and widen it only for the specific job that needs more:
permissions:
contents: read
jobs:
comment-on-pr:
permissions:
pull-requests: write
runs-on: ubuntu-latest
steps: [...]Pin third-party actions, especially ones that touch secrets
uses: some-org/deploy-action@v2 trusts whatever code that tag points to today — a tag can move. Pinning to a full commit SHA fixes exactly what runs, which matters most for third-party actions that also receive a secret via with:, since a compromised action then has that secret in scope. actions/* and other first-party actions are lower-risk by convention, but the SHA-pinning habit costs nothing either way.
Make manual runs configurable with workflow_dispatch inputs
workflow_dispatch alone just adds a "Run workflow" button; adding inputs: turns it into a small form, so a one-off manual run (deploy this specific tag, run against this specific environment) doesn't require editing the workflow file first:
on:
workflow_dispatch:
inputs:
environment:
description: "Environment to deploy"
required: true
type: choice
options: [staging, production]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh ${{ inputs.environment }}Try it yourself
Check a workflow against several of these directly: GitHub Actions Validator catches structural mistakes before you push, GitHub Actions Workflow Visualizer shows the real job order a matrix or needs: chain produces, GitHub Actions Linter flags missing permissions:/timeout-minutes and unpinned actions, and GitHub Actions Secrets Checker traces exactly where a workflow's secrets can leak. All four run entirely in your browser.