#!/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: * * HEALTHLOG_PROD_URL=https://healthlog.example.com \ * pnpm dlx tsx scripts/assert-deploy.ts * * The prod target's base URL comes from HEALTHLOG_PROD_URL (required * unless --only=demo). * * 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; } // The production base URL is instance-specific — there is no meaningful // default for a self-hosted app. Set HEALTHLOG_PROD_URL (e.g. // `https://healthlog.example.com`) before asserting the prod target. const PROD_BASE_URL = process.env.HEALTHLOG_PROD_URL?.replace(/\/+$/, ""); const TARGETS: Record = { prod: PROD_BASE_URL ? { name: "prod", url: `${PROD_BASE_URL}/api/version` } : undefined, 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) { if (!(only in TARGETS)) { console.error(`unknown --only target: ${only} (use prod or demo)`); process.exit(2); } const picked = TARGETS[only]; if (!picked) { console.error( `target "${only}" needs HEALTHLOG_PROD_URL set to the instance base URL (e.g. https://healthlog.example.com)`, ); process.exit(2); } selected = [picked]; } else { if (!TARGETS.prod) { console.error( "HEALTHLOG_PROD_URL is not set — set it to the instance base URL (e.g. https://healthlog.example.com) or use --only=demo", ); process.exit(2); } selected = Object.values(TARGETS).filter((t): t is Target => Boolean(t)); } 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();