CI/CD pipelines, from commit to canary
What should a pipeline do between a merged commit and production traffic, and how do you make it safe to ship often?
A pipeline turns a commit into one immutable, verified artifact, then promotes that same artifact through environments while shifting real traffic gradually. Speed comes from caching and small changes; safety comes from short-lived credentials, pinned dependencies, provenance and a rollback you have rehearsed.
THE MENTAL MODEL
Treat the pipeline as a factory with a single output: an artifact identified by its digest. Everything before it (lint, types, tests, scans) decides whether it is built; everything after it (staging, canary, production) decides how much traffic it gets, and none of those steps may rebuild it. Deploying is then a pointer move, and rolling back is moving the pointer back. The pipeline itself is production infrastructure with access to your cloud, so it gets the same threat model: every third-party action is code you run with your credentials, every untrusted input (PR titles, branch names, forks) can try to become a shell command, and every long-lived key in CI is a key that can leak.
HOW IT FITS TOGETHER
- Commit / pull requestSmall change on a short-lived branch. CI runs on the PR and again on the merge to main, with read-only token permissions.
- BuildFrozen lockfile install with a dependency cache keyed on the lockfile hash. Output is reproducible from the commit.
- Test and scanLint, typecheck, unit and integration tests, dependency and secret scanning. Fail fast; run slow suites in parallel.
- Artifact + provenanceImage or bundle pushed by digest, with a signed SLSA provenance attestation that ties it to the commit and workflow.
- StagingDeploy the digest with staging config. Smoke tests, migrations rehearsed, contract tests against real dependencies.
- Production (progressive)Approval gate if you practice continuous delivery. Canary or blue/green moves traffic in steps, guarded by SLO metrics.
- Verify or roll backAutomated analysis promotes or aborts. Rollback re-points traffic to the previous digest; no rebuild, no new commit.
| How traffic moves | Rollback | Extra capacity | Main risk | |
|---|---|---|---|---|
| Recreate | Stop all old instances, then start new ones | Redeploy old version; downtime both ways | None | Guaranteed downtime; fine only for dev or singletons |
| Rolling update | Replace instances in batches behind the load balancer | Roll forward or back batch by batch; slow | maxSurge pods or VMs | Mixed versions serve traffic; bad build reaches everyone |
| Blue/green | Full new stack idle, then switch the router at once | Switch back instantly while blue is kept | A second full environment | 100% of users hit the new version at the same moment |
| Canary | Small share of traffic first, raised in steps | Abort shifts traffic back to stable automatically | A few canary instances | Needs good metrics; low-traffic canaries hide errors |
| Feature flags | Code ships dark; a flag targets users or a percentage | Turn the flag off, no deploy | None | Flag debt and untested flag combinations |
KEY TERMS
- CI, continuous delivery, continuous deployment
- Continuous integration merges small changes to main frequently and verifies each one automatically. Continuous delivery keeps main always releasable, with a human deciding when to release. Continuous deployment removes that gate: every change that passes the pipeline goes to production. Deployment (code running) and release (users seeing it) can be separated with flags.
- Build once, promote by digest
- Rebuilding per environment means production runs something staging never tested (new transitive dependency, different base image). Build one artifact, address it by content digest, inject environment config at deploy time, and promote that digest. Rollback becomes re-pointing to the previous digest.
- Cache vs artifact
- A cache is a speed-up keyed on inputs (the lockfile hash) that may be missing or evicted; correctness must never depend on it. An artifact is a job output passed to later jobs or kept as the release. Never cache build outputs you then ship without rebuilding or verifying.
- OIDC federation instead of stored keys
- The job requests a short-lived signed JWT from the CI provider (GitHub needs permissions: id-token: write) and exchanges it with the cloud for temporary credentials. The cloud trust policy checks claims such as sub (repo, branch or environment), so only a specific workflow on a specific ref can assume the deploy role.
- Supply-chain hardening
- Third-party actions run with your token and secrets. Pin them to full commit SHAs (tags are mutable), keep GITHUB_TOKEN read-only by default, avoid pull_request_target with untrusted checkouts, and pass untrusted strings through env vars rather than interpolating them into run scripts. SLSA Build L2 adds signed provenance from a hosted builder; L3 adds isolation from user-defined steps.
- Progressive delivery
- Canary and blue/green turn a deploy into a measured experiment: shift a slice of traffic, compare error rate and latency against the stable version, then promote or abort. The analysis must be automated and tied to SLOs, or the canary is just a slow rollout.
- Rollback compatibility
- You can only roll back code if the previous version still works with the current data. Use expand/contract migrations (add columns, dual-write, backfill, switch reads, remove later) so schema changes ship separately from, and before, the code that needs them.
IN YOUR STACK
YAML · GitHub Actions Server
name: ci
on:
pull_request:
push:
branches: [main]
permissions:
contents: read # read-only GITHUB_TOKEN unless a job asks for more
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }} # never cancel a deploy on main
jobs:
verify:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1, reads packageManager
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with: { node-version: 24, cache: pnpm } # caches the pnpm store, keyed on pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- run: pnpm lint && pnpm typecheck && pnpm test
- run: pnpm build
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: dist-${{ github.sha }}
path: dist/- setup-node only auto-enables caching for npm; pnpm needs cache: pnpm, and pnpm/action-setup must run first so the pnpm binary exists. It caches the store, not node_modules.
- pnpm/action-setup takes the version from the packageManager field when version is omitted. pnpm also publishes pnpm/setup, which installs pnpm and Node in one step for pnpm 11+.
- cancel-in-progress stops superseded PR runs. It is off for pushes to main because the deploy job below shares this workflow, and a cancelled half-deploy is worse than a queued one.
YAML · GitHub Actions + AWS Server
deploy:
needs: verify
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
environment: production # required reviewers and env secrets live here
permissions:
id-token: write # lets the job mint an OIDC JWT; grants no repo write access
contents: read
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with: { name: "dist-${{ github.sha }}", path: dist/ }
- uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: arn:aws:iam::123456789012:role/web-deploy-prod
aws-region: eu-west-1
- run: aws s3 sync dist/ s3://example-web-prod --delete
# IAM role trust policy, Condition.StringEquals:
# "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
# "token.actions.githubusercontent.com:sub": "repo:my-org/web:environment:production"
# Repos created after 2026-07-15 use the immutable form:
# "repo:my-org@<owner-id>/web@<repo-id>:environment:production"- No AWS keys are stored anywhere: the action exchanges the JWT through sts:AssumeRoleWithWebIdentity for credentials that expire with the job.
- Always constrain sub (and aud). A trust policy that only checks the issuer lets any repository on GitHub assume your role.
- When a job uses environment, sub is environment-scoped rather than ref-scoped, so protection rules on the environment become part of the cloud trust boundary.
YAML · GitHub Actions + attest Server
name: release
on:
push:
tags: ["v*"]
permissions: {} # nothing by default; each job opts in
jobs:
build:
runs-on: ubuntu-24.04
permissions:
contents: read
id-token: write # Sigstore signing certificate
attestations: write # store the attestation
artifact-metadata: write # artifact storage record
steps:
# Full commit SHAs: a tag can be moved to malicious code, a SHA cannot.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: { persist-credentials: false } # don't leave the token in .git/config
- run: make dist/app.tar.gz
- uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-path: dist/app.tar.gz # SLSA build provenance by default
# Consumer, before deploying:
# gh attestation verify dist/app.tar.gz -R my-org/my-repo- Keep the version comment next to each SHA so Dependabot or Renovate can propose updates; verify the SHA belongs to the upstream repository, not a fork.
- GitHub's workflow dependency lockfile (.github/workflows/actions.lock, generated with the gh actions-lock extension) pins transitive actions too and supersedes SHA pinning for onboarded workflows; reusable workflows still need SHA references.
- actions/attest-build-provenance v4 is now a thin wrapper over actions/attest. Attestations in private repositories require GitHub Enterprise Cloud.
YAML · Argo Rollouts Server
apiVersion: argoproj.io/v1alpha1
kind: Rollout # replaces the Deployment; spec.template is unchanged
metadata: { name: orders }
spec:
strategy:
canary:
analysis: # background analysis; failure aborts and shifts traffic back
templates: [{ templateName: success-rate }]
startingStep: 1
steps:
- setWeight: 10
- pause: { duration: 10m }
- setWeight: 50
- pause: { duration: 10m }
- pause: {} # waits for: kubectl argo rollouts promote orders
---
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata: { name: success-rate }
spec:
metrics:
- name: success-rate
interval: 2m
failureLimit: 1
successCondition: result[0] >= 0.99
provider: { prometheus: { address: "http://prometheus.monitoring:9090", query: "sum(rate(http_requests_total{app=\"orders\",code!~\"5..\"}[2m])) / sum(rate(http_requests_total{app=\"orders\"}[2m]))" } }- Without a traffic router (Istio, Gateway API, NGINX, ALB...), setWeight is approximated by replica counts: 10% of 10 replicas is 1 canary pod.
- A pause without a duration waits indefinitely for a manual promote; use it as the human gate in continuous delivery.
- Selector, replicas and pod template are omitted here; they are the same as in the Deployment the Rollout replaces.
YAML · GitLab CI/CD Server
stages: [build, deploy]
default:
image: ghcr.io/pnpm/pnpm:12
before_script:
- pnpm config set store-dir .pnpm-store
- pnpm runtime set node 24 -g
build:
stage: build
cache: { key: { files: [pnpm-lock.yaml] }, paths: [.pnpm-store] }
script:
- pnpm install --frozen-lockfile
- pnpm lint && pnpm test && pnpm build
artifacts: { paths: [dist/], expire_in: 1 week }
deploy-production:
stage: deploy
image: { name: amazon/aws-cli:latest, entrypoint: [""] } # the image's entrypoint is aws; pin a digest in real pipelines
before_script: []
environment: production
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual # continuous delivery: a human presses deploy
id_tokens:
GITLAB_OIDC_TOKEN: { aud: https://gitlab.example.com }
script:
- eval "$(./scripts/assume-role.sh "$GITLAB_OIDC_TOKEN")" # prints export AWS_... lines
- aws s3 sync dist/ s3://example-web-prod --delete- cache is best-effort reuse between pipelines; artifacts are guaranteed job outputs passed to later stages (deploy receives dist/ from build).
- assume-role.sh stands for the documented aws sts assume-role-with-web-identity call. A child script cannot export into the job shell, so it prints export lines for eval (or inline the documented commands); the trust policy should match the sub claim (project_path:group/project:ref_type:branch:ref:main).
- Protect the production environment and the default branch so only maintainers can trigger the manual job.
YAML · Cloudflare wrangler-action Server
deploy-worker:
needs: verify
runs-on: ubuntu-24.04
environment: production
permissions: { contents: read }
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: upload # new version, receives no traffic yet
uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: versions upload --tag ${{ github.sha }} --message "${{ github.sha }}"
- run: ./scripts/check-version.sh # smoke test via a preview URL
- uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: versions deploy ${{ env.NEW_VERSION_ID }}@10% ${{ env.STABLE_VERSION_ID }}@90% -y
# Roll back instantly to the previous version (all routes at once):
# pnpm wrangler rollback --message "error rate above SLO"- Plain command: deploy uploads and sends 100% of traffic in one step. versions upload plus versions deploy gives a canary at the edge.
- NEW_VERSION_ID and STABLE_VERSION_ID are placeholders: read them from wrangler versions list or the upload step's output before the split.
- The API token is a long-lived secret: scope it to Workers edit on one account, store it as an environment secret behind protection rules, and rotate it.
WHEN TO REACH FOR WHAT
Continuous deployment
Use when Strong automated tests, automated canary analysis and fast rollback; web services where small frequent changes reduce risk.
Avoid when Releases need regulatory sign-off, or the app ships through app stores where you cannot roll back a binary.
Continuous delivery (manual gate)
Use when You want every commit releasable but need a human to pick the moment: coordinated launches, audited environments, early-stage test coverage.
Avoid when The gate becomes a rubber stamp that batches days of changes into one risky release.
Rolling update
Use when Stateless services with good readiness probes and backward-compatible changes; the default in Kubernetes and most PaaS.
Avoid when You need instant rollback or cannot tolerate old and new versions serving traffic together.
Blue/green
Use when Instant cutover and instant rollback matter more than cost, or smoke tests must run against the full new stack before users see it.
Avoid when A second full environment is too expensive, or stateful components cannot be duplicated cleanly.
Canary with automated analysis
Use when Enough traffic for metrics to be meaningful within minutes and SLO-based signals (error rate, latency) to compare against.
Avoid when Low-traffic services, where a 5% canary sees a handful of requests and proves nothing.
Feature flags
Use when Decoupling release from deploy: dark launches, per-tenant rollouts, kill switches for risky paths.
Avoid when Flags are never removed; plan the cleanup when you add the flag, or the combinations become untestable.
WHERE IT BITES
- Rebuilding the artifact per environment. Production then runs bytes nobody tested; build once and promote by digest.
- Long-lived cloud keys stored as CI secrets. They leak through logs, forks and compromised actions; federate with OIDC and constrain the sub claim.
- Referencing third-party actions by tag. Tags are mutable, and a compromised tag runs attacker code with your secrets; pin full SHAs or adopt the workflow lockfile.
- Interpolating untrusted context (PR titles, branch names) directly into run: scripts, or checking out fork code under pull_request_target. Both turn a PR into code execution with write tokens.
- Shipping a schema change and the code that needs it in one deploy. The rollback then breaks against the migrated database; use expand/contract.
- A rollback path nobody has exercised. If reverting requires a new build or a manual console session, it will be slow exactly when it matters.
CLOSE THE AI. EXPLAIN THIS.
A canary at 10% shows error rates climbing. Walk through what aborts it, where traffic goes, which artifact production is now running, and what would have prevented a clean rollback if the release included a database migration.WHEN IT BREAKS IN PRODUCTION
SOURCES
- GitHub Docs: Secure use reference (opens in new tab)Checked
- GitHub Docs: OpenID Connect reference (opens in new tab)Checked
- GitHub Docs: Workflow dependency lockfiles (opens in new tab)Checked
- SLSA v1.2: Build track basics (opens in new tab)Checked
- Argo Rollouts: Canary deployment strategy (opens in new tab)Checked
- GitLab Docs: Configure OpenID Connect in AWS (opens in new tab)Checked
Explainer reviewed