Cheatsheet

Workflow YAML you look up every single time

Grouped by what you are trying to do: triggers, job wiring, matrix builds, the expression functions nobody memorizes, caching that actually hits, secrets and permissions, reusable workflows, concurrency, and artifacts. Every entry is real syntax you can paste, and every group ends with the gotcha that costs an afternoon.

Action versions verified August 2026. Building a pipeline from scratch? Start with the CI/CD pipeline guide.

Triggers: the on block

Workflows live in .github/workflows/*.yml and start with what wakes them up.

Syntax Fires when
on: push Any push to any branch or tag in the repository.
on: {push: {branches: [main]}} Pushes to main only. The usual gate for a deploy workflow.
tags: ['v*.*.*'] Tag pushes matching a glob. The standard release trigger.
paths: ['src/**', 'package.json'] Only when a changed file matches. Cheap way to skip irrelevant runs.
paths-ignore: ['docs/**', '**.md'] The inverse. Skips documentation-only commits entirely.
on: pull_request Opened, synchronize, and reopened by default. The check that protects the branch.
types: [opened, ready_for_review] Narrow the pull request activity that counts. Skips draft churn.
on: workflow_dispatch Adds a Run workflow button in the UI, plus optional typed inputs.
on: {schedule: [{cron: '0 3 * * 1'}]} Cron in UTC. Mondays at 03:00 here. Five fields, no seconds.
on: {release: {types: [published]}} A GitHub Release going public. Publish packages from here, not from a tag push.
on: {workflow_run: {workflows: [CI]}} Chain a workflow onto another workflow's completion, across workflow files.
on: workflow_call Makes this file a reusable workflow other workflows can call as a job.
on: {repository_dispatch: {types: [deploy]}} Triggered by an external POST to the API. How other systems start a run.

Gotcha: scheduled workflows are disabled automatically after 60 days without repository activity, and cron runs are queued rather than guaranteed - a job set for 0 * * * * often starts several minutes late. Never schedule anything that depends on running exactly on the hour.

Jobs, steps, and dependencies

Key What it does
runs-on: ubuntu-latest Hosted runner image. Also windows-latest, macos-latest, ubuntu-24.04-arm.
runs-on: [self-hosted, linux, x64] Match a self-hosted runner by label set. All labels must match.
needs: [build, test] Wait for those jobs and skip if either fails. This is what makes it a pipeline.
timeout-minutes: 15 Kill the job at 15 minutes. The default is 360, which is six billable hours.
continue-on-error: true Failure does not fail the run. Use for advisory steps such as a flaky lint rule.
if: github.ref == 'refs/heads/main' Conditional job or step. Already an expression context, so no braces needed.
steps: - uses: actions/checkout@v7 Run a published action. Current major for checkout is v7.
- run: npm ci Run a shell command on the runner. Use a block scalar for multiple lines.
working-directory: ./apps/web Run one step somewhere other than the repository root.
defaults: {run: {shell: bash}} Set shell or working directory once for every run step in the job.
container: {image: 'node:22-alpine'} Run all steps inside a container instead of directly on the runner.
services: {db: {image: 'postgres:18'}} Sidecar containers for integration tests, reachable by service name.
outputs: {url: '${{ steps.d.outputs.url }}'} Publish a job output so a downstream job can read needs.job.outputs.url.
environment: {name: production} Attach the job to a protected environment with its own secrets and reviewers.

Gotcha: a job whose needs failed is skipped, and a skipped job is neither success nor failure - so a cleanup job with if: always() also runs when the run was cancelled. Use if: ${{ !cancelled() }} when you want "ran regardless of failure, but stop if I hit cancel".

Matrix builds

Key What it does
strategy: {matrix: {node: [20, 22, 24]}} Three parallel jobs, one per value, each billed separately.
matrix: {os: [...], node: [...]} Two dimensions produce the cartesian product. Three dimensions gets expensive fast.
fail-fast: false Let every cell finish instead of cancelling siblings on the first red one.
max-parallel: 2 Throttle concurrency, usually to be kind to a shared test database.
include: [{os: ubuntu-latest, cov: true}] Add variables to a matching combination, or create an extra one-off job.
exclude: [{os: windows-latest, node: 20}] Remove combinations you do not support. Applied before include.
matrix: {shard: [1, 2, 3, 4]} Split one slow suite four ways, then pass matrix.shard to the test runner.
matrix: ${{ fromJSON(needs.s.outputs.m) }} Dynamic matrix: an earlier job emits JSON, this job expands it.
name: test (node ${{ matrix.node }}) Readable check names, which matters when you set required status checks.

Gotcha: fail-fast defaults to true, so the first failing cell cancels the rest and you diagnose one failure instead of four. Also, if you make a matrix job a required status check, the check name includes the matrix values - change a version and the branch protection rule silently stops matching.

Expressions and contexts

Anything inside ${{ }} is an expression. Contexts are the objects available to it.

Expression Value
github.sha Full commit SHA. The right thing to tag an image or artifact with.
github.ref / github.ref_name Full ref (refs/heads/main) versus the short name (main).
github.event_name push, pull_request, workflow_dispatch, schedule, and so on.
github.repository / github.actor owner/name, and the username that triggered the run.
github.run_id / run_number / run_attempt Unique run id, incrementing count, and which retry this is.
github.event.pull_request.number The raw webhook payload is all under github.event.
runner.os / runner.arch / runner.temp Linux, X64, and a scratch directory. runner.os belongs in every cache key.
steps.build.outputs.url Output of an earlier step, addressed by that step's id.
needs.build.outputs.version Output of an upstream job, addressed by job id.
env.X / vars.X / secrets.X / inputs.X Environment variable, repository variable, secret, and dispatch or call input.
startsWith(github.ref, 'refs/tags/') The idiomatic "only on a tag" condition. endsWith and contains match too.
contains(github.event.head_commit.message, 'skip ci') Substring test on a string, or membership test on an array.
format('{0}-{1}', runner.os, matrix.node) String interpolation without nesting expressions inside expressions.
toJSON(github) / fromJSON(needs.s.outputs.m) Serialize a context for debugging, or parse JSON into a real object or array.
hashFiles('**/package-lock.json') Hash of every matching file. The correct suffix for a dependency cache key.
success() / failure() / cancelled() / always() Status checks for if conditions. success() is the implicit default.

Gotcha: the env context is not available in runs-on or in a job-level if, which is why an obviously correct condition sometimes evaluates to empty. Use vars or a job output there instead. And when a value could be attacker-controlled, such as a pull request title, pass it through env: rather than interpolating it straight into a run: line - that is a script injection.

Caching that actually hits

Syntax What it does
uses: actions/setup-node@v7 Installs Node and, with cache set, wires up dependency caching for you.
with: {cache: npm} One line, keyed off the lockfile. Also accepts pnpm and yarn.
cache-dependency-path: '**/pnpm-lock.yaml' Point at the real lockfile when it is not at the repository root.
uses: actions/cache@v6 Manual caching for anything setup-node does not know about.
key: ${{ runner.os }}-x-${{ hashFiles('...') }} OS plus a content hash. Never a static string, or the cache freezes forever.
restore-keys: ${{ runner.os }}-x- Prefix fallbacks, tried in order, so a lockfile change still gets a warm start.
steps.cache.outputs.cache-hit == 'true' Skip the expensive step on an exact hit. String comparison, not boolean.
lookup-only: true Check whether a key exists without downloading it. Useful for gating jobs.
fail-on-cache-miss: true Fail loudly when a job requires a cache an earlier job should have written.
actions/cache/restore@v6 and /save@v6 Split restore and save when you only want to write the cache on success.
path: ~/.cache/ms-playwright Browser binaries. Also worth caching: .next/cache, .turbo, ~/.cargo, ~/go/pkg/mod.

Gotcha: cache entries are immutable. Writing to a key that already exists is a silent no-op, so a key without a content hash in it will serve stale contents indefinitely. Caches are also branch-scoped - a branch can read caches from its base branch, but the base cannot read a branch's - and entries unused for 7 days are evicted, as are the oldest entries once a repository passes its 10 GB limit.

Secrets, variables, and permissions

Syntax What it does
secrets.GITHUB_TOKEN Automatic per-run token, scoped by the permissions block. No setup needed.
permissions: {contents: read} Least privilege for the token. Put this at the top of every workflow.
permissions: {id-token: write} Enables OIDC so AWS, GCP, or Azure can issue short-lived credentials.
permissions: {} Drop every scope. The right default for a job that only runs tests.
env: {NODE_ENV: production} Set at workflow, job, or step level, with the narrowest scope winning.
vars.API_BASE_URL Repository or environment variable for non-sensitive configuration.
echo "KEY=value" >> "$GITHUB_ENV" Export a variable to every later step in the same job.
echo "name=value" >> "$GITHUB_OUTPUT" Set a step output. Replaced the removed set-output workflow command.
echo "/opt/tool/bin" >> "$GITHUB_PATH" Prepend to PATH for subsequent steps.
echo "::add-mask::$VALUE" Redact a computed value from the logs the way a secret would be.
environment: production Scopes environment secrets and applies required reviewers and wait timers.

Gotcha: pull requests from forks get a read-only token and no secrets, by design. The workaround people reach for - pull_request_target combined with checking out the pull request head - runs untrusted code with full secret access and is the most common way public repositories are compromised. If a fork build needs a credential, split it into a second workflow triggered by workflow_run.

Reusable workflows and composite actions

Syntax What it does
on: {workflow_call: {inputs: {...}}} Declare typed inputs (string, number, boolean) for a callable workflow.
workflow_call: {secrets: {TOKEN: {required: true}}} Declare which secrets the caller must pass explicitly.
jobs.ci.uses: ./.github/workflows/ci.yml Call a workflow in the same repository as a job. Note uses at job level, not step.
uses: acme/ci/.github/workflows/build.yml@v1 Call a shared workflow from another repository, pinned to a ref.
secrets: inherit Pass every caller secret down instead of listing them one by one.
runs: {using: composite, steps: [...]} A composite action in action.yml: a bundle of steps you drop into any job.
shell: bash Mandatory on every run step inside a composite action. Omitting it is a hard error.
uses: ./.github/actions/setup Use a local composite action. Requires a checkout step before it.

Gotcha: the two abstractions are not interchangeable. A composite action bundles steps inside an existing job and inherits its runner; a reusable workflow brings its own jobs and its own runs-on. Reusable workflows nest at most four levels deep, and neither one inherits the caller's env - pass values through with: or you will chase empty strings.

Concurrency, artifacts, and checkout

Syntax What it does
concurrency: {group: ci-${{ github.ref }}} One run per branch. A second run queues behind the first.
cancel-in-progress: true Kill the superseded run instead of queueing. The biggest single cost saving.
concurrency: {group: deploy-production} A global lock. Never set cancel-in-progress on a deploy.
uses: actions/upload-artifact@v7 Hand files to a later job, or keep a build for a rollback.
with: {name: dist, path: dist/, retention-days: 14} Name, contents, and how long before it is deleted. Default is 90 days.
if-no-files-found: error Fail instead of silently uploading nothing when a glob matches no files.
uses: actions/download-artifact@v8 Pull an artifact into a downstream job so it never rebuilds.
with: {pattern: 'dist-*', merge-multiple: true} Collect every per-matrix artifact into one directory.
actions/checkout@v7 with fetch-depth: 0 Full history. Required by changelog generation and by affected-package tooling.
with: {submodules: recursive} Checkout ignores submodules unless you ask for them.
echo "## Results" >> "$GITHUB_STEP_SUMMARY" Markdown rendered on the run page. Far better than making people read logs.
echo "::group::Install" and ::endgroup:: Collapsible log sections, which make a long run readable.

Gotcha: since v4 of upload-artifact, artifact names must be unique within a run - uploading the same name twice fails outright rather than merging as older versions did. In a matrix, suffix the name with the matrix values and use pattern plus merge-multiple on the way back down.

Keep going

These keys assembled into a working pipeline - verify, preview deploy, production release, rollback - are in the CI/CD pipeline guide. If you are still choosing a platform, CI/CD tools compared prices GitHub Actions against GitLab CI, CircleCI, and Buildkite, and GitHub Actions vs GitLab CI takes the closest call head to head.

Most workflows end up building an image, so the Docker cheatsheet covers the commands your run steps will be calling, and the Git cheatsheet covers the refs and ranges these expressions keep referring to.