diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index f5bfb0082..e22a1d7f7 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -59,3 +59,20 @@ roll back: dev --create-only --name vX_Y_Z_rollback_`), edit the SQL, commit, and re-deploy. Never `migrate resolve --rolled-back` against production unless you've taken a fresh `pg_dump` first. + +## Verify the served version + +The queued-deploy status is **not** the source of truth — the version +`/api/version` actually answers is. A BuildKit layer-cache hit can +re-ship the prior image even when Coolify reports a clean deploy, so +always confirm the served build after a deploy: + +```sh +# Asserts prod + demo serve 1.9.0, prints each target's buildSha, +# exits non-zero on a mismatch or timeout after a bounded retry loop. +pnpm dlx tsx scripts/assert-deploy.ts 1.9.0 +``` + +Pass `--only=prod` / `--only=demo` to check a single target, or +`--attempts=N --interval=ms` to widen the retry window while a slow +pull settles. diff --git a/scripts/assert-deploy.ts b/scripts/assert-deploy.ts new file mode 100644 index 000000000..ea1e677d7 --- /dev/null +++ b/scripts/assert-deploy.ts @@ -0,0 +1,169 @@ +#!/usr/bin/env tsx +/** + * Post-deploy version assertion. + * + * Polls `/api/version` on production + demo and asserts the running + * build's `data.version` matches an expected value, printing each + * target's `buildSha`. Exits non-zero on a mismatch or after the retry + * window elapses — so a CI step or a manual deploy can fail loudly when + * the `:latest` pull served the prior image (the recurring BuildKit + * layer-cache pitfall where Coolify reports "deployed" while + * `/api/version` still answers the old number). + * + * Run it after every deploy — the queued-deploy status is not the source + * of truth, the served version is: + * + * pnpm dlx tsx scripts/assert-deploy.ts + * + * Examples: + * + * # assert both prod + demo serve 1.9.0 + * pnpm dlx tsx scripts/assert-deploy.ts 1.9.0 + * + * # assert prod only + * pnpm dlx tsx scripts/assert-deploy.ts 1.9.0 --only=prod + * + * # widen the retry window (default 24 attempts × 5s = 2 min) + * pnpm dlx tsx scripts/assert-deploy.ts 1.9.0 --attempts=60 --interval=5000 + * + * Pure script — strips the leading `v` from the expected version so both + * `v1.9.0` and `1.9.0` work. No app-library import, no runtime wiring; + * the standalone image never ships this file. + */ + +interface Target { + name: string; + url: string; +} + +const TARGETS: Record = { + prod: { name: "prod", url: "https://healthlog.bombeck.io/api/version" }, + demo: { name: "demo", url: "https://demo.healthlog.dev/api/version" }, +}; + +interface VersionPayload { + version?: string; + buildSha?: string | null; + builtAt?: string | null; +} + +function parseArgs(argv: string[]): { + expected: string; + attempts: number; + intervalMs: number; + only: string | null; +} { + const positional: string[] = []; + let attempts = 24; + let intervalMs = 5_000; + let only: string | null = null; + + for (const arg of argv) { + if (arg.startsWith("--attempts=")) { + attempts = Number.parseInt(arg.slice("--attempts=".length), 10); + } else if (arg.startsWith("--interval=")) { + intervalMs = Number.parseInt(arg.slice("--interval=".length), 10); + } else if (arg.startsWith("--only=")) { + only = arg.slice("--only=".length); + } else { + positional.push(arg); + } + } + + const expected = (positional[0] ?? "").trim().replace(/^v/, ""); + if (!expected) { + console.error( + "usage: pnpm dlx tsx scripts/assert-deploy.ts [--only=prod|demo] [--attempts=N] [--interval=ms]", + ); + process.exit(2); + } + if (!Number.isFinite(attempts) || attempts < 1) attempts = 24; + if (!Number.isFinite(intervalMs) || intervalMs < 0) intervalMs = 5_000; + + return { expected, attempts, intervalMs, only }; +} + +const sleep = (ms: number): Promise => + new Promise((res) => setTimeout(res, ms)); + +/** One probe. Returns the parsed payload or null on any transport / shape error. */ +async function probe(url: string): Promise { + try { + const res = await fetch(url, { + redirect: "manual", + signal: AbortSignal.timeout(10_000), + headers: { accept: "application/json" }, + }); + if (!res.ok) return null; + const body = (await res.json()) as { data?: VersionPayload } | null; + return body?.data ?? null; + } catch { + return null; + } +} + +/** + * Poll one target until its served version matches `expected` or the + * retry window elapses. Resolves true on a match, false otherwise. + */ +async function assertTarget( + target: Target, + expected: string, + attempts: number, + intervalMs: number, +): Promise { + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const payload = await probe(target.url); + const version = payload?.version?.trim() ?? null; + if (version === expected) { + console.log( + `[${target.name}] OK version=${version} buildSha=${payload?.buildSha ?? "—"}`, + ); + return true; + } + const seen = version ?? "unreachable"; + console.log( + `[${target.name}] attempt ${attempt}/${attempts}: expected ${expected}, got ${seen}`, + ); + if (attempt < attempts) await sleep(intervalMs); + } + console.error( + `[${target.name}] FAIL: ${target.url} did not report ${expected} within ${attempts} attempts`, + ); + return false; +} + +async function main(): Promise { + const { expected, attempts, intervalMs, only } = parseArgs( + process.argv.slice(2), + ); + + let selected: Target[]; + if (only) { + const picked = TARGETS[only]; + if (!picked) { + console.error(`unknown --only target: ${only} (use prod or demo)`); + process.exit(2); + } + selected = [picked]; + } else { + selected = Object.values(TARGETS); + } + + console.log( + `Asserting version ${expected} on: ${selected.map((t) => t.name).join(", ")}`, + ); + + // Probe targets in parallel; each runs its own bounded retry loop. + const results = await Promise.all( + selected.map((t) => assertTarget(t, expected, attempts, intervalMs)), + ); + + if (results.every((ok) => ok)) { + console.log("All targets serve the expected version."); + process.exit(0); + } + process.exit(1); +} + +void main();