Canary releases

A canary release puts a new version beside the stable one, exposes it to a small share of traffic, watches it, and either widens the exposure step by step until it is promoted or rolls it back. @zuke/canary builds that whole chain from one settings lambda — and every way the rollout can go wrong runs one rollback.

A rollout in one declaration

canary(...) is a component: assign it to a field and its targets appear under that field's name. The rest of the build depends on them like any other target — here, ship runs only once the release is promoted.

import { Build, run, target } from "jsr:@zuke/core";
import { canary, httpProbe, prometheus } from "jsr:@zuke/canary";

class Deploy extends Build {
  rollout = canary((c) =>
    c.platform(cloudRun) // anything implementing CanaryPlatform
      .steps(10, 25, 50) // exposure after each step; promotion is the 100 %
      .bake("10m") // per step; .bakeStep(3, "30m") overrides one
      .analysis(
        httpProbe((h) => h.url(HEALTH_URL).samples(30).maxFailures(0)),
        prometheus((p) => p.url(PROMETHEUS).query(ERROR_RATIO).max(0.01)),
      )
      .approval("canary-approved") // optional gate before promotion
      .approvalTimeout("24h") // and how long it waits
      .lock((l) => l.lockKey("deploy", "api").withTtl("24h"))
  );

  ship = target().dependsOn(this.rollout.promote).executes(() => {});
}

await run(Deploy);

A failed analysis, a failed step, an approval that times out, and zuke cancel all end in the same place: rollout.abort, which returns every user to the stable version.

The targets

TargetWhat it does
rollout.stage Puts the candidate in place at 0 %. Takes the lock for the whole run. A stage that fails part-way, or is cancelled, rolls back what it left before it fails.
rollout.stepN.expose Sets the exposure of step N. Exposure is absolute, and the summary shows what the platform achieved: Exposure: 12.5% (requested 10%).
rollout.stepN.bake Waits out the step's bake. Inline bakes run the analyses on an interval while they wait; durable ones suspend the run.
rollout.stepN.analyze Runs every analysis once more at the end of the step.
rollout.approve Waits for the approval signal. A timeout rolls the canary back.
rollout.promote Sends everything to the candidate. It is a crash-durable effect, so a resume re-drives one that was interrupted.
rollout.abort The rollback. It runs as stage's compensation whenever the run is cancelled or a step fails with cancel-run, unless the release was already promoted. It can be run by hand.

A target only exists when it has something to do: a step with no bake has no bake, and a canary with no analyses has no analyze. A canary with no steps — on a release-channel platform, for example — gets a single rollout.soak phase when it bakes or analyses.

Requires a state store

Rolling back means knowing which targets succeeded, and the platform's handles have to survive a suspend, so a canary needs a state store. A build with state disabled is refused when the rollout starts.

Platforms

The engine never talks to Cloud Run, Kubernetes or anything else directly. It calls a CanaryPlatform, and the adapters for specific platforms live in their tool's wrapper package. Each adapter's lambda runs on every call, so it may read resolved parameters.

PlatformAdapterExposure
Cloud Run cloudRunCanary from @zuke/gcloud An exact traffic split through the candidate revision's tag, in whole percents.
Kubernetes kubectlCanary from @zuke/kubectl A stable and a canary Deployment behind one Service; the canary's share of replicas. The canary grows and is ready before stable shrinks.
Helm helmCanary from @zuke/helm The candidate as a second release; its share of the replicas.
Docker Compose dockerComposeCanary from @zuke/docker-compose A stable and a canary service behind a balancing proxy; the canary's share of a fixed replica total.

Cloud Run

import { cloudRunCanary } from "jsr:@zuke/gcloud";

c.platform(
  cloudRunCanary((r) =>
    r.service("api").region("europe-west1").image(this.image.value)
      .gcloud((g) => g.project("my-project"))
  ),
)

stage deploys the image as a new revision with no traffic and the canary tag; each step moves the tag's share with update-traffic; promote sends everything to the latest revision after checking it is still the staged one.

Kubernetes

import { kubectlCanary } from "jsr:@zuke/kubectl";

c.platform(
  kubectlCanary((k) =>
    k.stable("api").canary("api-canary").container("api")
      .image(this.image.value).replicas(10).namespace("prod")
      .kubectl((s) => s.context("prod"))
  ),
)

Give the two Deployments a shared label that one Service selects. A Service balances connections, not requests, so with keep-alive or HTTP/2 clients the real share can differ from the replica share — use a traffic platform when you need an exact request split. Suspend any HorizontalPodAutoscaler or GitOps self-heal that owns either Deployment during a rollout.

Helm

import { helmCanary } from "jsr:@zuke/helm";

c.platform(
  helmCanary((h) =>
    h.chart("./charts/api").stableRelease("api").namespace("prod")
      .image(this.tag.value).replicas(10).values("values-prod.yaml")
      .helm((s) => s.kubeContext("prod"))
  ),
)

The candidate runs as <stable>-canary unless you name it with .canaryRelease(...). A rollback returns the stable release to the revision stage recorded and uninstalls the canary.

Docker Compose

import { dockerComposeCanary } from "jsr:@zuke/docker-compose";

c.platform(
  dockerComposeCanary((d) =>
    d.service("app").canaryService("app-canary").replicas(4)
      .image(this.image.value)
      .stableImageVariable("APP_IMAGE")
      .canaryImageVariable("APP_CANARY_IMAGE")
      .compose((s) => s.file("compose.yml").projectName("shop"))
  ),
)

Compose has no command that sets a service's image, so each service's image: must be a whole variable that the platform sets on every command it runs.

Every adapter records what it staged, and later calls refuse — changing nothing — when the configuration no longer matches. The full canary guide lists each adapter's exact commands, the checks it runs, and how to recover from a refusal.

Your own platform

Until an adapter exists for yours, a platform is a plain object in the build:

MemberContract
exposure "traffic" (an exact split), "replicas" (quantised to whole instances) or "channel" (a release channel, no steps).
describe()A short name for the summary.
stage(ctx)Candidate in place at 0 %. Record what a rollback needs in ctx.state.
expose(percent, ctx)Set exposure to percent (absolute) and return the exposure achieved. Repeating it is harmless.
promote(ctx)Send everything to the candidate. Idempotent.
abort(ctx)Return everything to the stable version. Idempotent, and must cope with empty state.
import { Build, run, target } from "jsr:@zuke/core";
import { $ } from "jsr:@zuke/core/shell";
import { canary, type CanaryPlatform, httpProbe } from "jsr:@zuke/canary";

// Two Docker Compose services behind a weighted proxy, scripted by hand.
const compose: CanaryPlatform = {
  exposure: "replicas",
  describe: () => "compose service api",
  async stage(ctx) {
    await $`docker compose up -d --scale api-canary=1 api-canary`;
    await ctx.state.set({ stable: "api", candidate: "api-canary" });
  },
  async expose(percent) {
    const canaries = Math.round(percent / 25);
    await $`docker compose up -d --scale api-canary=${canaries} api-canary`;
    return canaries * 25;
  },
  async promote() {
    await $`docker compose up -d api`;
    await $`docker compose rm -sf api-canary`;
  },
  async abort() {
    // Needs no state: the stable service is fixed by configuration.
    await $`docker compose rm -sf api-canary`;
  },
};

class Deploy extends Build {
  rollout = canary((c) =>
    c.platform(compose)
      .steps(25, 50)
      .bake("5m")
      .analysis(httpProbe((h) => h.url("http://localhost:8080/healthz")))
  );
  ship = target().dependsOn(this.rollout.promote).executes(() => {});
}

await run(Deploy);

Every call gets the same ctx.state, so abort can read what stage wrote even in another process after a resume. Never put a secret in it: it is persisted as plain JSON and shown by zuke runs show. ctx.signal aborts when the run is cancelled — pass it to network calls.

Bakes: inline or durable

By default a bake is inline: the bake target sleeps in the process while the analyses run every .analysisInterval(...) (default "1m"). The first failing check stops the bake at once and rolls back; zuke cancel or Ctrl-C ends it too. An inline bake is a timer, so it can be at most about 24 days.

.durable() makes each bake a gate instead. The run suspends at it, so no process is alive during the bake, and zuke resume --check reopens it once the time is up. A durable bake runs no in-flight analyses, since nothing is there to run them; each step's final analyze still runs in whichever process resumes the run. Use durable bakes when a bake is long, or when the CI job that started the rollout should not stay alive for it.

rollout = canary((c) =>
  c.platform(cloudRun)
    .steps(10, 25, 50)
    .bake("6h")
    .durable() // each bake suspends the run; no process stays alive
);
# from a cron: reopen every parked gate whose time is up
zuke resume --check

Analyses

An analysis is anything with a validate(ctx) that throws to fail, so any core Validation works unchanged — an @zuke/ai review included. A canary-aware one also reads where the rollout stands: ctx.step, ctx.requested and ctx.exposure. The package ships three, all over plain HTTP:

AnalysisFails when
httpProbe((h) => h.url(u).samples(30).maxFailures(1)) More than maxFailures of samples requests fail: a status outside expectStatus(...) (any 2xx by default), a timeout, or a connection error.
prometheus((p) => p.url(u).query(q).max(0.01)) Any sample of the instant query is out of bounds, or the query cannot be read. An empty result fails: append or vector(0) when no data means healthy.
metricThreshold((m) => m.name("p99").read(fn).max(0.5)) The number your reader returns is out of bounds.

For anything beyond bounds on one instant query — a quantile you reduce yourself, a range, a rule's state, the firing alerts — read it with PrometheusTasks from @zuke/prometheus, the same typed client prometheus(...) queries through, and judge it in a metricThreshold(...) reader. PrometheusTasks.value returns the one number an instant query answered, and fails clearly on an empty or multi-sample result.

import { metricThreshold } from "jsr:@zuke/canary";
import { PrometheusTasks } from "jsr:@zuke/prometheus";

metricThreshold((m) =>
  m.name("p99 seconds").max(0.5).read(async () =>
    PrometheusTasks.value(
      await PrometheusTasks.query((q) => q.url(PROMETHEUS).query(P99_LATENCY)),
    )
  )
)

Failure messages pass through the run's redactor, so a secret parameter in a URL or a query is masked before it reaches a log. A ratio query over a window with no traffic is 0/0, which Prometheus reports as NaN — and a NaN sample fails. Guard such a query so quiet means healthy.

.connection(...) hands the rest of the connection to @zuke/prometheus's own settings, so a canary reads a managed Prometheus with the cloud's own credentials — no gcloud, az or aws CLI on the runner:

// Amazon Managed Service for Prometheus, signed with SigV4.
prometheus((p) =>
  p.name("error ratio")
    .url("https://aps-workspaces.us-east-1.amazonaws.com/workspaces/ws-1234")
    .query(ERROR_RATIO).max(0.01)
    .connection((c) => c.sigv4((a) => a.region("us-east-1")))
)

// Google Cloud Managed Service for Prometheus, through Application Default
// Credentials; c.azure() does the same for Azure Monitor.
prometheus((p) =>
  p.name("error ratio")
    .url("https://monitoring.googleapis.com/v1/projects/my-project/location/global/prometheus")
    .query(ERROR_RATIO).max(0.01)
    .connection((c) => c.google())
)

The approval gate

.approval("canary-approved") adds rollout.approve before the promotion: the run suspends until the signal arrives, and .approvalTimeout("24h") bounds the wait — a timeout rolls the canary back.

zuke resume <run-id> --signal canary-approved

The lock

.lock((l) => l.lockKey("deploy", "api").withTtl("24h")) takes a core lock held from staging until the run settles, across every bake and approval wait, so a second rollout of the same service is refused until the first is promoted or rolled back. Set the TTL to cover the longest the run can stay parked: it is how long a parked rollout keeps the lock with no process alive.

Rolling back

There is one rollback, rollout.abort, and every path reaches it:

What happenedHow it reaches abort
An analysis or an in-flight check failedthe step's .onFailure("cancel-run") cancels the run
expose or promote failedthe same
stage failed part-way, or was cancelledstage calls the platform's abort itself before it fails
The approval timed outthe gate's .onTimeout("cancel-run")
zuke cancel <run-id>the cancellation walk runs stage's compensation

Two cases deliberately do not roll back:

  • Another rollout lost the lock. It fails before its stage body runs, so it touches nothing; the candidate belongs to the rollout holding the lock.
  • The release was already promoted. A cancellation later in the same run leaves the promoted release alone instead of returning traffic to a version promotion already retired.

Running zuke rollout.abort by hand is different. It starts a fresh run with no recorded state, so each adapter needs the version to go back to from its own configuration — .stable(...), .stableImage(...) or .stableRevision(...) — and refuses without one. To stop a rollout that is still running or parked, use zuke cancel <run-id> instead: it rolls back with the recorded state and releases the rollout's lock.