skills-ws
Version:
85 agent skills for AI coding assistants — marketing, growth, web3, dev, design & operations. Built for OpenClaw, Claude Code, Cursor, and Codex.
654 lines (563 loc) • 25.8 kB
Markdown
name: cicd-pipelines
description: "Production GitHub Actions CI/CD: least-privilege workflows, caching, OIDC deploys, Docker multi-stage builds, blue-green/canary, rollback, release automation (semantic-release, Changesets), and the 2026 supply-chain baseline. Use when writing/reviewing .github/workflows, automating releases, or debugging caching/secrets/OIDC."
# 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 **June 2026**; pin by SHA in regulated/high-trust repos (see [Supply-Chain Baseline](#supply-chain-baseline-2026)). For sibling depth on container internals see `docker-production`; for cloud IAM specifics see `aws-production-deploy`.
## Action Version Matrix (June 2026)
Pin to these majors (or the exact SHA for the major). All listed majors run on the **Node 24** runtime — GitHub forced the Actions runtime default to Node 24 on **2026-06-02**, so any action still on Node 20 will warn/fail; bump them.
| Action | Pin | Notes |
|---|---|---|
| `actions/checkout` | `@v6` | v6 changed git config injection; bare `git` commands still work via `includeIf`. |
| `actions/setup-node` | `@v6` | |
| `actions/cache` | `@v5` | |
| `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` | `@v3` | v4 exists but v3 is the stable default line; check the docs before jumping. |
| `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 `releases` page; 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.
```yaml
# .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.
```yaml
# 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
```yaml
# 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: 9 }
- 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@v5
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).
```yaml
# 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):**
```jsonc
// 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`/`print` a secret; GitHub masks known values but interpolation can defeat masking.
- Never pass secrets into actions triggered by `pull_request` from forks (use `pull_request_target` only 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@v2` can 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.
```dockerfile
# 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
COPY --from=deps /app/node_modules ./node_modules # PROD deps only
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
```yaml
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
```yaml
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)
```yaml
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)
```yaml
# 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.
```json
// .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"] }]
]
}
```
```yaml
# .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 automation token (or use OIDC trusted publishing where supported).
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
```
> With `id-token: write` + a recent npm CLI, `@semantic-release/npm` publishes with **provenance**, linking the package on npm to the exact workflow run that built it. Prefer npm "trusted publishing" (OIDC) over a static `NPM_TOKEN` where 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.
```bash
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
```
```yaml
# .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: 9 }
- 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 }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
```
```jsonc
// .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:
```yaml
# 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.
```
```yaml
# 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
```bash
# 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:**
1. Revert traffic immediately — do not debug in prod.
2. Verify the rollback with health checks / SLO dashboards.
3. Communicate in the incident channel (who, what, ETA).
4. Root-cause only after stability is restored.
5. 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.
1. **Pin actions by commit SHA, not tag.** Tags are mutable — a compromised maintainer can repoint `v6` to 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`.
2. **Least-privilege `permissions:`** at the top level (`contents: read`), widened per-job only as shown above.
3. **Pin runner images** (`ubuntu-24.04`), not `ubuntu-latest`, so an image migration is a deliberate, reviewed change.
4. **Dependency review on PRs** — block merges that introduce known-vulnerable or disallowed-license deps:
```yaml
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
```
5. **SBOM generation** for releases (SPDX/CycloneDX), attached to the release or image:
```yaml
- uses: anchore/sbom-action@v0.24
with: { format: spdx-json, output-file: sbom.spdx.json }
```
6. **Provenance attestations + image signing** — see the [build-image job](#build-scan-sign-and-attest-in-ci) (`actions/attest-build-provenance@v4`, `cosign sign`). This is your SLSA build-provenance layer; verify signatures/attestations at deploy time before promoting an image.
7. **Harden the runner** for sensitive jobs: `step-security/harden-runner@v2` to block unexpected egress and detect tampering.
8. **CodeQL** for code scanning on a schedule + PRs (`github/codeql-action@v3`); route SARIF to the Security tab.
## Status Badges
```markdown
[](https://github.com/your-org/your-repo/actions/workflows/ci.yml)
[](https://codecov.io/gh/your-org/your-repo)
```
## CI Performance Tips
- Use `concurrency` with `cancel-in-progress` to kill superseded PR runs.
- Run lint/typecheck/test as **parallel jobs**, not sequential steps on one runner.
- Use `paths`/`paths-ignore` filters 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)
```yaml
# .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
```yaml
# .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
```yaml
# .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@v3
with: { languages: javascript-typescript }
- uses: github/codeql-action/analyze@v3
```
> Also enable **Dependabot** (`.github/dependabot.yml`) with a `github-actions` ecosystem entry so your pinned action SHAs get bumped automatically:
> ```yaml
> version: 2
> updates:
> - package-ecosystem: github-actions
> directory: "/"
> schedule: { interval: weekly }
> - package-ecosystem: npm
> directory: "/"
> schedule: { interval: weekly }
> ```