← Back to the library
OperationsHow it works · Applied · 14 min

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

Build once, promote the same artifactStages left of the artifact gate whether it exists; stages right of it only change how much traffic it receives.
  1. 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.
  2. BuildFrozen lockfile install with a dependency cache keyed on the lockfile hash. Output is reproducible from the commit.
  3. Test and scanLint, typecheck, unit and integration tests, dependency and secret scanning. Fail fast; run slow suites in parallel.
  4. Artifact + provenanceImage or bundle pushed by digest, with a signed SLSA provenance attestation that ties it to the commit and workflow.
  5. StagingDeploy the digest with staging config. Smoke tests, migrations rehearsed, contract tests against real dependencies.
  6. Production (progressive)Approval gate if you practice continuous delivery. Canary or blue/green moves traffic in steps, guarded by SLO metrics.
  7. Verify or roll backAutomated analysis promotes or aborts. Rollback re-points traffic to the previous digest; no rebuild, no new commit.
Deployment strategiesAll of them assume the old and new versions can run side by side against the same database schema.
How traffic movesRollbackExtra capacityMain risk
RecreateStop all old instances, then start new onesRedeploy old version; downtime both waysNoneGuaranteed downtime; fine only for dev or singletons
Rolling updateReplace instances in batches behind the load balancerRoll forward or back batch by batch; slowmaxSurge pods or VMsMixed versions serve traffic; bad build reaches everyone
Blue/greenFull new stack idle, then switch the router at onceSwitch back instantly while blue is keptA second full environment100% of users hit the new version at the same moment
CanarySmall share of traffic first, raised in stepsAbort shifts traffic back to stable automaticallyA few canary instancesNeeds good metrics; low-traffic canaries hide errors
Feature flagsCode ships dark; a flag targets users or a percentageTurn the flag off, no deployNoneFlag 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.
GitHub Actions documentation (opens in new tab)

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.
GitHub Actions + AWS documentation (opens in new tab)

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.
GitHub Actions + attest documentation (opens in new tab)

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.
Argo Rollouts documentation (opens in new tab)

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.
GitLab CI/CD documentation (opens in new tab)

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.
Cloudflare wrangler-action documentation (opens in new tab)

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.

SOURCES

Explainer reviewed