---
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
[![CI](https://github.com/your-org/your-repo/actions/workflows/ci.yml/badge.svg)](https://github.com/your-org/your-repo/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/your-org/your-repo/branch/main/graph/badge.svg)](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 }
> ```
