Skip to content

CI/CD Cheat Sheet

Pipeline patterns, caching, matrix builds, and deployment strategies for GitHub Actions.

Workflows live in .github/workflows/*.yml and run on GitHub-hosted or self-hosted runners.

name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests
run: npm test
on:
push:
branches: [main, release/*]
tags: ["v*"]
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
schedule:
- cron: "0 6 * * 1" # Every Monday at 6am UTC
workflow_dispatch: # Manual trigger
inputs:
environment:
description: "Deploy target"
required: true
default: "staging"
type: choice
options: [staging, production]
workflow_run:
workflows: ["Build"]
types: [completed]
release:
types: [published]
on:
push:
paths:
- "packages/api/**"
- "shared/**"
paths-ignore:
- "docs/**"
- "*.md"
env:
NODE_ENV: production # Workflow-level
jobs:
deploy:
runs-on: ubuntu-latest
env:
REGION: us-east-1 # Job-level
steps:
- name: Deploy
env:
API_KEY: ${{ secrets.API_KEY }} # Step-level
run: ./deploy.sh
- name: Use GitHub context
run: |
echo "Repo: ${{ github.repository }}"
echo "SHA: ${{ github.sha }}"
echo "Ref: ${{ github.ref_name }}"
echo "Actor: ${{ github.actor }}"
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-

How keys work: exact match on key first, then prefix match through restore-keys in order. Cache saves only on exact-key miss.

# Node.js (npm)
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
# Node.js (pnpm)
- uses: actions/cache@v4
with:
path: ~/.pnpm-store
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
# Python (pip)
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('**/requirements*.txt') }}
# Python (uv)
- uses: actions/cache@v4
with:
path: ~/.cache/uv
key: uv-${{ runner.os }}-${{ hashFiles('**/uv.lock') }}
# Go modules
- uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: go-${{ runner.os }}-${{ hashFiles('**/go.sum') }}
# Rust (cargo)
- uses: actions/cache@v4
with:
path: |
~/.cargo/bin
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
target
key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}

Many setup actions handle caching internally — prefer these over manual cache steps.

# Node.js
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm # or pnpm, yarn
# Python
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip # or poetry, pipenv
# Go
- uses: actions/setup-go@v5
with:
go-version: "1.22"
cache: true # Caches go-build and go modules
ProblemSymptomFix
Stale dependenciesTests pass in CI, fail locallyKey on lock file hash, not branch
Cache poisoningCorrupted cache breaks all runsDelete cache via API or change key prefix
Bloated cacheRestore slower than fresh installNarrow path, cache only expensive parts
Cache thrashingKey changes every commitUse stable hash inputs (lock files only)
Terminal window
# Delete a cache via CLI
gh cache delete --all
gh cache list
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node-version: [18, 20, 22]
fail-fast: false # Don't cancel siblings on failure
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm test
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
node-version: [18, 20]
include:
- os: ubuntu-latest # Add a combo with extra var
node-version: 22
experimental: true
exclude:
- os: macos-latest # Remove a specific combo
node-version: 18
jobs:
generate:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- id: set-matrix
run: echo 'matrix=["api","web","worker"]' >> "$GITHUB_OUTPUT"
build:
needs: generate
runs-on: ubuntu-latest
strategy:
matrix:
service: ${{ fromJson(needs.generate.outputs.matrix) }}
steps:
- run: echo "Building ${{ matrix.service }}"

Define in .github/workflows/reusable-test.yml:

name: Reusable Test
on:
workflow_call:
inputs:
node-version:
required: false
type: string
default: "20"
secrets:
npm-token:
required: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- run: npm ci
env:
NPM_TOKEN: ${{ secrets.npm-token }}
- run: npm test

Call from another workflow:

jobs:
test:
uses: ./.github/workflows/reusable-test.yml
with:
node-version: "22"
secrets:
npm-token: ${{ secrets.NPM_TOKEN }}

Define in .github/actions/setup-project/action.yml:

name: Setup Project
description: Install deps and build
inputs:
node-version:
description: Node.js version
default: "20"
runs:
using: composite
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: npm
- run: npm ci
shell: bash
- run: npm run build
shell: bash

Use it:

steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-project
with:
node-version: "22"
FeatureReusable WorkflowComposite Action
ScopeFull job(s) with runnersSteps within a job
Defined in.github/workflows/action.yml anywhere
Can define jobsYesNo (steps only)
Can use secretsYes (explicit passing)Yes (via inputs)
Can use if:Yes (job and step level)Yes (step level)
Best forStandardized CI across reposShared setup/teardown logic

Two identical environments. Switch traffic after validation.

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy to green
run: ./deploy.sh --target green
- name: Smoke test green
run: ./smoke-test.sh --target green
- name: Switch traffic to green
run: ./switch-traffic.sh --from blue --to green
- name: Keep blue as rollback
run: echo "Blue available for instant rollback"

Route a small percentage of traffic to the new version, then increase gradually.

jobs:
canary:
runs-on: ubuntu-latest
steps:
- name: Deploy canary (5% traffic)
run: ./deploy.sh --canary --weight 5
- name: Monitor error rate (10 min)
run: ./monitor.sh --duration 600 --threshold 0.01
- name: Promote to 50%
run: ./deploy.sh --canary --weight 50
- name: Monitor again
run: ./monitor.sh --duration 600 --threshold 0.01
- name: Full rollout
run: ./deploy.sh --promote
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
steps:
- run: ./deploy.sh staging
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment:
name: production
url: https://myapp.com
steps:
- run: ./deploy.sh production

Configure in GitHub repo settings: required reviewers, wait timers, branch restrictions.

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Save current version
run: |
CURRENT=$(./get-current-version.sh)
echo "rollback_version=$CURRENT" >> "$GITHUB_ENV"
- name: Deploy
run: ./deploy.sh --version ${{ github.sha }}
- name: Smoke test
id: smoke
run: ./smoke-test.sh
continue-on-error: true
- name: Rollback on failure
if: steps.smoke.outcome == 'failure'
run: ./deploy.sh --version ${{ env.rollback_version }}
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
build:
needs: [lint, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
deploy:
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: dist
- run: ./deploy.sh
steps:
- name: Deploy to production
if: github.ref == 'refs/heads/main'
run: ./deploy.sh production
- name: Deploy preview
if: github.event_name == 'pull_request'
run: ./deploy-preview.sh
- name: Run only on tag
if: startsWith(github.ref, 'refs/tags/v')
run: ./release.sh
- name: Skip for bot commits
if: github.actor != 'dependabot[bot]'
run: npm test
- name: Run on failure only
if: failure()
run: ./notify-slack.sh "Build failed"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
retention-days: 5
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
- run: ls dist/

Configure in repo Settings > Branches > Branch protection rules:

  • Require status checks to pass before merging
  • Require branches to be up to date before merging
  • Require specific checks by job name
# Job names become status check names
jobs:
ci: # Status check: "ci"
runs-on: ubuntu-latest
steps:
- run: npm test
# Cancel in-progress runs for the same branch
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Never cancel production deploys
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
version:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.get-tag.outputs.tag }}
steps:
- id: get-tag
run: echo "tag=v$(date +%Y%m%d)" >> "$GITHUB_OUTPUT"
deploy:
needs: version
runs-on: ubuntu-latest
steps:
- run: echo "Deploying ${{ needs.version.outputs.tag }}"
# Workflow-level — restrict all jobs
permissions:
contents: read
# Job-level override
jobs:
deploy:
permissions:
contents: read
id-token: write # For OIDC
deployments: write

Default permissions: {} grants nothing. Always start minimal and add what you need.

# Bad — tag can be overwritten
- uses: actions/checkout@v4
# Good — immutable commit SHA
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

Use StepSecurity Harden Runner or Dependabot to keep pinned SHAs updated.

Eliminate long-lived cloud credentials by exchanging a short-lived GitHub token.

permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions
aws-region: us-east-1
# No access key or secret key needed
Terminal window
# Rotate secrets
gh secret set API_KEY --body "new-value"
# List secrets (names only, not values)
gh secret list
# Environment-scoped secrets
gh secret set DB_PASSWORD --env production --body "value"
  • Never echo secrets in logs (add-mask if unavoidable)
  • Use environment-scoped secrets for production credentials
  • Rotate on team member departure
  • Prefer OIDC over stored credentials
Terminal window
# Install
brew install act
# Run default event (push)
act
# Run specific workflow
act -W .github/workflows/ci.yml
# Run specific job
act -j test
# List workflows without running
act -l
# Use specific runner image
act -P ubuntu-latest=catthehacker/ubuntu:act-latest
# Pass secrets
act -s GITHUB_TOKEN="$(gh auth token)"
# Pass event payload
act pull_request -e event.json
# Set repository secret ACTIONS_RUNNER_DEBUG=true
# or add to workflow:
env:
ACTIONS_RUNNER_DEBUG: true
ACTIONS_STEP_DEBUG: true
Terminal window
# Re-run with debug logging via CLI
gh run rerun 12345 --debug
- name: Debug via SSH
if: failure()
uses: mxschmitt/action-tmate@v3
with:
limit-access-to-actor: true # Only PR author can connect
Terminal window
# View run status
gh run list
gh run view 12345
gh run view 12345 --log
# Watch a run in progress
gh run watch
# View specific job log
gh run view 12345 --job 67890 --log
I want to…Use
Run on push to mainon: push: branches: [main]
Run on PRon: pull_request:
Run on scheduleon: schedule: - cron: "0 6 * * 1"
Trigger manuallyon: workflow_dispatch:
Cache npm depsactions/setup-node@v4 with cache: npm
Run on multiple OS/versionsstrategy: matrix:
Share workflows across reposuses: org/repo/.github/workflows/x.yml@main
Pass data between jobsoutputs: + $GITHUB_OUTPUT
Upload build artifactsactions/upload-artifact@v4
Require approval before deployenvironment: with protection rules
Cancel redundant runsconcurrency: group: + cancel-in-progress: true
Run step only on mainif: github.ref == 'refs/heads/main'
Run step only on failureif: failure()
Filter by changed fileson: push: paths: [...]
Pin action for securityuses: actions/checkout@SHA
Deploy without long-lived credentialsOIDC with id-token: write permission
Test workflows locallyact CLI
Debug a failed rungh run view ID --log or ACTIONS_RUNNER_DEBUG
Rerun a failed workflowgh run rerun ID