CI/CD Pipelines
Concrete, runnable patterns for production GitHub Actions pipelines. Every snippet below is self-contained: copy it, swap the placeholders, and ship. Action versions are current as of July 2026; pin by SHA in regulated/high-trust repos (see Supply-Chain Baseline). For sibling depth on container internals see docker-production; for cloud IAM specifics see aws-production-deploy.
Action Version Matrix (July 2026)
Pin to these majors (or the exact SHA for the major). All listed majors run on the Node 24 runtime. GitHub switched the Actions runtime default to Node 24 on 2026-06-16, so any action still on Node 20 warns (the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true opt-out lasts only until Node 20 is removed in fall 2026); bump them.
| Action | Pin | Notes |
|---|---|---|
actions/checkout | @v7 | v7 blocks checking out fork PR heads under pull_request_target/workflow_run. |
actions/setup-node | @v6 | |
actions/cache | @v6 | v6 = ESM migration; v5 still receives releases. |
actions/upload-artifact | @v7 | v7 adds non-zip uploads (compression-level/archive). Not symmetric with download. |
actions/download-artifact | @v8 | Pairs with upload v7; major numbers differ — don't assume they match. |
actions/attest-build-provenance | @v4 | Now a thin wrapper over actions/attest@v4. |
docker/build-push-action | @v7 | v7 = Node 24 default; requires runner ≥ v2.327.1 (GitHub-hosted is fine). |
docker/setup-buildx-action | @v4 | |
docker/login-action | @v4 | |
docker/metadata-action | @v6 | |
aws-actions/configure-aws-credentials | @v6 | |
actions/dependency-review-action | @v5 | |
github/codeql-action | @v4 | v4 is the latest supported line; v3 remains only for older GHES (3.16-3.19). |
codecov/codecov-action | @v7 | Requires CODECOV_TOKEN for public repos since v4. |
sigstore/cosign-installer | @v4 | |
anchore/sbom-action | @v0.24 | 0.x — pin the exact minor, no stable major yet. |
aquasecurity/trivy-action | @v0.36 | 0.x — pin the exact minor. |
step-security/harden-runner | @v2 | Egress filtering / runtime monitoring. |
changesets/action | @v1 (v1.9+) |
These move fast. The durable source of truth is each action's
releasespage; verify before pinning a SHA for production.
GitHub Actions — Core CI Workflow
Set least-privilege permissions at the top level (contents: read) so every job defaults to read-only; grant writes only on the specific job that needs them. This is the single highest-leverage hardening step — a compromised dependency in a test job then cannot push code or mint releases.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
# Least-privilege default for ALL jobs. Override per-job when a job needs more.
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
# Pin the runner image. `ubuntu-latest` silently migrates (e.g. 24.04 -> 26.04)
# and can break builds mid-sprint. Pin the version; bump it deliberately.
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
# Node 20 reached EOL 2026-04-30 — dropped. 22 = maintenance LTS (until 2027-04),
# 24 = active LTS. Only matrix versions you actually support in production.
node: [22, 24]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm test -- --coverage
- uses: actions/upload-artifact@v7
with:
name: coverage-${{ matrix.node }}
path: coverage/
retention-days: 7
# Split lint/typecheck into their own job so they run in parallel with tests,
# not as sequential steps that serialize the critical path.
lint:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with: { node-version: 24, cache: npm }
- run: npm ci
- run: npm run lint
- run: npm run typecheck
Required status checks & branch protection
CI only protects you if merges are blocked on it. Configure once (Settings → Branches → branch protection rule, or via API/Terraform):
- Require pull request before merging; require ≥1 approval; dismiss stale approvals on new commits.
- Require status checks to pass: add the exact job names (
test (22),test (24),lint). Matrix jobs register as separate checks — list each, or gate them behind one aggregator job. - Require branches to be up to date before merging (forces re-run against latest
main). - Require signed commits and a linear history on release branches if your compliance posture needs it.
# Aggregator pattern: make ONE check required instead of N flaky matrix entries.
ci-passed:
runs-on: ubuntu-24.04
needs: [test, lint]
if: always()
steps:
- name: Fail if any dependency failed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: exit 1
Caching Strategies
# 1) Node modules — let setup-node manage it (keyed on lockfile hash automatically).
- uses: actions/setup-node@v6
with: { node-version: 24, cache: npm } # use 'pnpm' or 'yarn' to match your PM
# 2) pnpm (needs the store + action-setup BEFORE setup-node's cache kicks in)
- uses: pnpm/action-setup@v6
with: { version: 11 } # or omit version to use the packageManager field from package.json
- uses: actions/setup-node@v6
with: { node-version: 24, cache: pnpm }
# 3) Docker layer caching via the GitHub Actions cache backend
- uses: docker/build-push-action@v7
with:
context: .
cache-from: type=gha
cache-to: type=gha,mode=max # mode=max also caches intermediate layers
# 4) Turborepo local cache (remote cache is better at scale — see monorepo section)
- uses: actions/cache@v6
with:
path: .turbo
# Include the lockfile in the key so a dep change busts the cache.
key: turbo-${{ hashFiles('**/turbo.json', '**/package-lock.json') }}-${{ github.sha }}
restore-keys: |
turbo-${{ hashFiles('**/turbo.json', '**/package-lock.json') }}-
- run: npx turbo build --cache-dir=.turbo
Cache hygiene: key on the lockfile hash (not loose globs), keep restore-keys as a prefix fallback, and never cache anything secret-derived. Untrusted PRs run in a restricted scope and cannot write to caches/branches your default branch created — don't design a workflow that depends on a PR populating a shared cache.
Secrets & OIDC
Prefer OIDC over long-lived static cloud keys: the workflow mints a short-lived token at runtime, so there is no secret to leak or rotate. id-token: write is required for OIDC and must be granted explicitly (it is not in the contents: read default).
# Repository / org secrets (Settings -> Secrets and variables -> Actions)
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
jobs:
# Environment-scoped secrets + manual gate. Secrets here are isolated from CI jobs.
deploy:
environment: production # add "Required reviewers" + secrets on this environment
permissions:
id-token: write # mint the OIDC token
contents: read
steps:
# OIDC — no stored cloud keys. Configure the trust policy on the cloud side
# to only accept tokens from THIS repo + ref (and ideally THIS environment).
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::123456789012:role/deploy
aws-region: us-east-1
- run: ./deploy.sh
Trust-policy scoping (do this — a wildcard repo:* subject is a takeover risk):
// AWS IAM trust policy condition — bind to exactly your repo, ref, and environment
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:environment:production"
}
}
Secret rules:
- Never
echo/printa secret; GitHub masks known values but interpolation can defeat masking. - Never pass secrets into actions triggered by
pull_requestfrom forks (usepull_request_targetonly with extreme care — it runs with write scope against untrusted code). - Prefer the auto-provisioned
GITHUB_TOKEN(scoped, short-lived) over a PAT. If you need a PAT, use a fine-grained token with minimal repo/permission scope and a short expiry. - Rotate any unavoidable static credential on a schedule and alert on use from unexpected IPs (
step-security/harden-runner@v2can enforce egress allowlists).
Docker Multi-Stage Build
The classic footgun: npm ci in the build stage (dev deps included for the build), then copying node_modules straight into the runtime image — shipping dev dependencies, tooling, and a larger attack surface to production. Fix it with a dedicated deps stage that installs production-only, and copy that into runtime.
# syntax=docker/dockerfile:1
# --- deps: production-only dependencies for the runtime image ---
FROM node:24-alpine AS deps
WORKDIR /app
COPY package*.json ./
# BuildKit cache mount keeps the npm cache warm across builds without baking it in.
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
# --- build: full deps (incl. dev) just to compile, never shipped ---
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
# --- runtime: minimal, non-root, prod deps only ---
FROM node:24-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -g 1001 app && adduser -u 1001 -G app -s /bin/sh -D app
# prod deps only (from the deps stage)
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER app
EXPOSE 3000
# Container-level liveness; pair with your orchestrator's probes.
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "dist/index.js"]
Build, scan, sign, and attest in CI
build-image:
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write # push to GHCR
id-token: write # keyless cosign signing + attestations
attestations: write # actions/attest-build-provenance
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha
type=semver,pattern={{version}}
- id: build
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Vulnerability scan — fail the build on fixable HIGH/CRITICAL CVEs.
- uses: aquasecurity/trivy-action@v0.36
with:
image-ref: ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
format: sarif
output: trivy.sarif
severity: HIGH,CRITICAL
ignore-unfixed: true
exit-code: '1'
# Keyless image signing (Sigstore/Fulcio — no key to manage).
- uses: sigstore/cosign-installer@v4
- run: cosign sign --yes ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
# Build provenance attestation (SLSA) bound to the pushed digest.
- uses: actions/attest-build-provenance@v4
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
Always reference images by digest (@sha256:...), never a mutable tag, downstream — that is what cosign signs and what provenance attests. Verify at deploy time: cosign verify --certificate-identity-regexp '...' --certificate-oidc-issuer https://token.actions.githubusercontent.com IMAGE@DIGEST.
Deployment Strategies
| Strategy | Downtime | Rollback Speed | Risk | Best For |
|---|---|---|---|---|
| Rolling | Zero | Minutes | Medium | Stateless services |
| Blue-Green | Zero | Instant (swap) | Low | Critical services |
| Canary | Zero | Fast (shift back) | Lowest | High-traffic APIs |
| Recreate | Yes | Slow | High | Dev/staging only |
Blue-Green with GitHub Actions
deploy:
runs-on: ubuntu-24.04
environment: production
permissions: { id-token: write, contents: read }
steps:
- uses: actions/checkout@v6
- name: Deploy to idle (green) slot
run: ./deploy.sh green
- name: Health check green before any traffic
run: |
for i in $(seq 1 30); do
curl -fsS https://green.app.example/health && exit 0
sleep 5
done
echo "green never became healthy"; exit 1
- name: Swap traffic to green
run: ./swap-traffic.sh green
- name: Keep blue warm as instant rollback
run: echo "Rollback = ./swap-traffic.sh blue (previous version still running)"
Canary (progressive traffic shift)
canary:
runs-on: ubuntu-24.04
environment: production
steps:
- uses: actions/checkout@v6
- run: ./deploy.sh canary
- name: Shift 5% → watch SLOs → 25% → 50% → 100%
run: |
for pct in 5 25 50 100; do
./set-weight.sh canary "$pct"
sleep 120
# Bail (and auto-rollback) if error rate / latency SLO breaches.
./check-slo.sh canary || { ./set-weight.sh canary 0; exit 1; }
done
Environment Promotion (dev → staging → prod)
# Trigger chain: push to main → dev → staging (auto) → prod (manual approval)
deploy-dev:
if: github.ref == 'refs/heads/main'
environment: dev
permissions: { id-token: write, contents: read }
deploy-staging:
needs: deploy-dev
environment: staging
permissions: { id-token: write, contents: read }
deploy-prod:
needs: deploy-staging
environment: production # set "Required reviewers" + a wait timer on this environment
permissions: { id-token: write, contents: read }
Release Automation
Option A — semantic-release (single package, automated versioning from commits)
semantic-release reads Conventional Commits, computes the next version, publishes to npm, creates the GitHub release, and commits the changelog — all in CI on main. The common failure is a release job missing Node setup, a clean install, or the npm auth token, so it either can't run or publishes unauthenticated.
// .releaserc.json
{
"branches": ["main", { "name": "next", "prerelease": true }],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
"@semantic-release/npm",
"@semantic-release/github",
["@semantic-release/git", { "assets": ["CHANGELOG.md", "package.json"] }]
]
}
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
permissions:
contents: read # least-privilege default; the release job widens it below
jobs:
release:
runs-on: ubuntu-24.04
permissions:
contents: write # push the changelog/version commit + create the GitHub release
issues: write # comment on released issues
pull-requests: write # comment on released PRs
id-token: write # npm provenance (publish with verifiable origin)
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # full history — semantic-release diffs all tags
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
registry-url: https://registry.npmjs.org # writes the npm authToken line
- run: npm ci
- run: npm run build --if-present
- run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# npm granular access token (write tokens max 90 days) or, preferably,
# OIDC trusted publishing: classic automation tokens were revoked 2025-12-09.
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
With
id-token: write+ a recent npm CLI,@semantic-release/npmpublishes with provenance, linking the package on npm to the exact workflow run that built it. Prefer npm "trusted publishing" (OIDC) over a staticNPM_TOKENwhere your registry supports it.
Option B — Changesets (monorepos, human-curated release notes)
Changesets is the better fit for monorepos: contributors drop an intent file per PR (npx changeset), and a bot opens/maintains a single "Version Packages" PR. Merging that PR versions every affected package and publishes them together.
npx changeset # developer: select bumped packages + write a summary (commit the .md)
npx changeset version # CI/maintainer: applies bumps, updates changelogs + lockfile
npx changeset publish # CI: publishes every package that has a new version
# .github/workflows/changesets.yml (inlined — this is the full workflow)
name: Changesets Release
on:
push:
branches: [main]
permissions:
contents: read
concurrency: changesets-${{ github.ref }} # serialize so two pushes can't double-publish
jobs:
release:
runs-on: ubuntu-24.04
permissions:
contents: write # create/update the "Version Packages" PR + tags
pull-requests: write # open/maintain the version PR
id-token: write # npm provenance on publish
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- uses: pnpm/action-setup@v6
with: { version: 11 } # or omit version to use the packageManager field from package.json
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
registry-url: https://registry.npmjs.org
- run: pnpm install --frozen-lockfile
- run: pnpm build
- id: changesets
uses: changesets/action@v1
with:
# If changesets exist -> publish. Otherwise -> open/refresh the Version PR.
publish: pnpm changeset publish
version: pnpm changeset version
commit: "chore: release packages"
title: "chore: release packages"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# npm granular access token (write tokens max 90 days) or, preferably,
# OIDC trusted publishing: classic automation tokens were revoked 2025-12-09.
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
// .changeset/config.json — key options
{
"$schema": "https://unpkg.com/@changesets/config/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"access": "public", // "restricted" for private scoped packages
"baseBranch": "main",
"updateInternalDependencies": "patch",
"linked": [], // [["@scope/a","@scope/b"]] to version in lockstep
"ignore": ["@scope/internal-tooling"]
}
Monorepo: build/test only what changed
Don't run the whole matrix on every PR. Two production-grade options:
# A) Turborepo affected graph (uses the cache + dependency graph)
- run: npx turbo run build test --filter='...[origin/main]'
# '...[origin/main]' = packages changed since main, PLUS everything that depends on them.
# Add a Remote Cache (Vercel or self-hosted) so CI reuses local/dev build artifacts.
# B) Path filters to skip irrelevant jobs entirely
on:
pull_request:
paths: ['packages/api/**', 'package-lock.json']
# Or use dorny/paths-filter to set per-area outputs and gate downstream jobs with `if:`.
For Nx use nx affected -t build test --base=origin/main --head=HEAD.
Rollback Procedures
# Kubernetes — roll back the last applied revision
kubectl rollout undo deployment/api
kubectl rollout status deployment/api --timeout=120s
# ECS — point the service back at the previous task-definition revision
aws ecs update-service --cluster prod --service api \
--task-definition api:PREVIOUS_REVISION --force-new-deployment
# Vercel / Netlify — instant promote of the prior deployment
vercel rollback # or: vercel promote <previous-deployment-url>
Rollback checklist:
- Revert traffic immediately — do not debug in prod.
- Verify the rollback with health checks / SLO dashboards.
- Communicate in the incident channel (who, what, ETA).
- Root-cause only after stability is restored.
- Add a regression test that reproduces the failure before re-deploying the fix.
Supply-Chain Baseline (2026)
The 2026 expectation for a trustworthy pipeline. Adopt top-to-bottom; the first three are the highest ROI.
- Pin actions by commit SHA, not tag. Tags are mutable — a compromised maintainer can repoint
v6to malicious code (this has happened in the wild). Use the SHA and let Dependabot bump it:uses: actions/checkout@<40-char-sha> # v6.0.2. - Least-privilege
permissions:at the top level (contents: read), widened per-job only as shown above. - Pin runner images (
ubuntu-24.04), notubuntu-latest, so an image migration is a deliberate, reviewed change. - Dependency review on PRs — block merges that introduce known-vulnerable or disallowed-license deps:
dependency-review: runs-on: ubuntu-24.04 permissions: { contents: read, pull-requests: write } steps: - uses: actions/checkout@v6 - uses: actions/dependency-review-action@v5 with: fail-on-severity: high comment-summary-in-pr: always - SBOM generation for releases (SPDX/CycloneDX), attached to the release or image:
- uses: anchore/sbom-action@v0.24 with: { format: spdx-json, output-file: sbom.spdx.json } - Provenance attestations + image signing — see the build-image job (
actions/attest-build-provenance@v4,cosign sign). This is your SLSA build-provenance layer; verify signatures/attestations at deploy time before promoting an image. - Harden the runner for sensitive jobs:
step-security/harden-runner@v2to block unexpected egress and detect tampering. - CodeQL for code scanning on a schedule + PRs (
github/codeql-action@v4); route SARIF to the Security tab.
Status Badges
[](https://github.com/your-org/your-repo/actions/workflows/ci.yml)
[](https://codecov.io/gh/your-org/your-repo)
CI Performance Tips
- Use
concurrencywithcancel-in-progressto kill superseded PR runs. - Run lint/typecheck/test as parallel jobs, not sequential steps on one runner.
- Use
paths/paths-ignorefilters to skip workflows that can't be affected by a change. - Cache aggressively but key on the lockfile hash — dependencies, build artifacts, Docker layers (
mode=max). - Matrix only what you actually ship (don't test 4 Node versions if you deploy one).
- Pin the runner image (
ubuntu-24.04) — predictable performance and no surprise migrations. - For monorepos, drive everything off the affected graph (Turborepo/Nx) instead of rebuilding the world.
Copy-Paste Starter Workflows
Minimal CI (single Node version, no matrix)
# .github/workflows/ci.yml
name: CI
on: { push: { branches: [main] }, pull_request: { branches: [main] } }
permissions: { contents: read }
concurrency: { group: ci-${{ github.ref }}, cancel-in-progress: true }
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with: { node-version: 24, cache: npm }
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test
- run: npm run build --if-present
Deploy to Vercel on push to main
# .github/workflows/deploy.yml
name: Deploy
on: { push: { branches: [main] } }
permissions: { contents: read }
jobs:
deploy:
runs-on: ubuntu-24.04
environment: production
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with: { node-version: 24, cache: npm }
- run: npm ci
- run: npx vercel deploy --prod --token "$VERCEL_TOKEN" --yes
env: { VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} }
Scheduled dependency + security maintenance
# .github/workflows/security.yml
name: Security
on:
schedule: [{ cron: '0 6 * * 1' }] # Mondays 06:00 UTC
workflow_dispatch:
permissions: { contents: read, security-events: write }
jobs:
codeql:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: github/codeql-action/init@v4
with: { languages: javascript-typescript }
- uses: github/codeql-action/analyze@v4
Also enable Dependabot (
.github/dependabot.yml) with agithub-actionsecosystem entry so your pinned action SHAs get bumped automatically:version: 2 updates: - package-ecosystem: github-actions directory: "/" schedule: { interval: weekly } - package-ecosystem: npm directory: "/" schedule: { interval: weekly }