/* oxlint-disable executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: out-of-band deploy-promotion CLI */ /** * Promote a specific, already-uploaded executor-cloud Worker version to a * chosen percentage of live traffic, using Cloudflare's staged * gradual-deployment model (`wrangler versions deploy`). * * This is the second half of the upload -> staged-promotion deploy flow * (see apps/cloud/DEPLOY.md and .github/workflows/deploy.yml). Merges upload a * version but route no traffic to it; this script promotes it deliberately. * * The version to promote is chosen EXPLICITLY, never inferred silently: * --version promote exactly that version id (from * `wrangler versions list`). This is what advances an * existing canary: re-run at a higher --percent for the SAME * --version so you widen the version you already reviewed, * not whatever landed on main since. * --version latest promote the most recently uploaded version. This is an * explicit opt-in for "promote what I just merged"; it is * NEVER the default, so advancing a rollout can never * accidentally promote unreviewed code. * * Percentage: * --percent 100 promote the chosen version to 100% (full cutover). * --percent 10 split traffic 10% chosen / 90% current-live version, for a * canary step. Re-run at a higher percentage (same version) * to advance; the DOs whose sessions land on the newly * promoted version restart, so keep early steps small and * watch telemetry between steps. * * IMPORTANT: a promotion that changes a Durable Object's assigned version * restarts that DO (validated empirically, DEPLOY.md experiments 3b/5). Stage * promotions to bound that blast radius. * * Reads no secrets beyond CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID (already * in the CI env). Config is the generated dist config the build produced. */ import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; // The generated dist config the build produced. Overridable via // PROMOTE_CLOUD_CONFIG for testing against a non-prod worker. const CONFIG = process.env.PROMOTE_CLOUD_CONFIG ?? "dist/server/wrangler.json"; // Target-worker guard: refuse to run against anything but executor-cloud unless // the operator explicitly names the worker they expect via // PROMOTE_CLOUD_ALLOW_NAME (for a non-prod worker). A promotion // routes live traffic and restarts DOs, so a misconfigured config that points at // the wrong worker must fail loudly, not promote the wrong thing. `--yes` does // NOT bypass this: it only skips wrangler's own interactive confirmation. const EXPECTED_WORKER_NAME = process.env.PROMOTE_CLOUD_ALLOW_NAME ?? "executor-cloud"; { let configName: unknown; try { configName = (JSON.parse(readFileSync(CONFIG, "utf8")) as { name?: unknown }).name; } catch (error) { throw new Error( `could not read wrangler config ${CONFIG} to verify the target worker: ${String(error)}`, ); } if (configName !== EXPECTED_WORKER_NAME) { throw new Error( `refusing to promote: wrangler config ${CONFIG} targets worker ${JSON.stringify(configName)}, expected ${JSON.stringify(EXPECTED_WORKER_NAME)}. ` + `Set PROMOTE_CLOUD_ALLOW_NAME to override for a non-production worker.`, ); } } const args = process.argv.slice(2); const argValue = (name: string): string | undefined => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }; const percentRaw = argValue("--percent"); if (!percentRaw) { throw new Error("--percent is required (1-100)"); } const percent = Number(percentRaw); if (!Number.isInteger(percent) || percent < 1 || percent > 100) { throw new Error(`--percent must be an integer 1-100, got: ${percentRaw}`); } // The version to promote is required and explicit: an id, or the literal // `latest` to opt in to "promote the most recently uploaded version". const versionArg = argValue("--version") ?? process.env.PROMOTE_VERSION; if (!versionArg) { throw new Error( "--version is required: pass an explicit version id (from `wrangler versions list`), " + "or the literal `latest` to promote the most recently uploaded version.", ); } const wrangler = (wranglerArgs: readonly string[]): string => { const result = spawnSync("bun", ["run", "wrangler", ...wranglerArgs], { encoding: "utf8", env: process.env, }); if (result.status !== 0) { throw new Error( `wrangler ${wranglerArgs.join(" ")} failed (${result.status}):\n${result.stderr || result.stdout}`, ); } return result.stdout; }; type VersionListEntry = { readonly id: string; readonly number: number; readonly metadata?: { readonly created_on?: string }; }; type DeploymentStatus = { readonly versions: ReadonlyArray<{ readonly version_id: string; readonly percentage: number }>; }; const versions = JSON.parse( wrangler(["versions", "list", "-c", CONFIG, "--json"]), ) as VersionListEntry[]; if (versions.length === 0) { throw new Error("no uploaded versions found"); } // Resolve the target version EXPLICITLY. `latest` is an opt-in that sorts by // version number (monotonic per upload) descending, since `versions list` // order is not guaranteed. Any other value is treated as a version id and must // match an uploaded version exactly, so a typo or a since-deleted id fails // loudly rather than promoting the wrong version. let target: VersionListEntry; if (versionArg === "latest") { const sorted = [...versions].sort((a, b) => { if (a.number !== b.number) return b.number - a.number; const at = a.metadata?.created_on ?? ""; const bt = b.metadata?.created_on ?? ""; return bt.localeCompare(at); }); target = sorted[0]; } else { const found = versions.find((v) => v.id === versionArg); if (!found) { throw new Error( `version ${versionArg} not found among uploaded versions. ` + `Run \`wrangler versions list -c ${CONFIG}\` to see valid ids, ` + "or pass `latest` to promote the most recent upload.", ); } target = found; } // The version(s) currently serving live traffic, so a partial promotion holds // the remainder on the current-live version rather than an arbitrary one. const status = JSON.parse( wrangler(["deployments", "status", "-c", CONFIG, "--json"]), ) as DeploymentStatus; // The version to hold the remainder of a partial promotion: the current-live // version carrying the most traffic that is NOT the one we are promoting. If // the only current version IS the target, there is nothing to split against. const currentLive = [...status.versions] .filter((v) => v.version_id !== target.id) .sort((a, b) => b.percentage - a.percentage)[0]?.version_id; if (target.id === currentLive && percent === 100) { console.log(`Version ${target.id} is already live at 100%; nothing to promote.`); process.exit(0); } const deployArgs = ["versions", "deploy", "-c", CONFIG, "--yes"]; if (percent === 100 || !currentLive || currentLive === target.id) { deployArgs.push(`${target.id}@100%`); } else { // Staged split: target@percent, current-live@remainder. deployArgs.push(`${target.id}@${percent}%`, `${currentLive}@${100 - percent}%`); } console.log(`Promoting ${target.id} (version #${target.number}) at ${percent}%...`); console.log(wrangler(deployArgs)); console.log("Done. Monitor telemetry, then re-run at a higher percentage to advance the rollout.");