Files
descrybe/scripts/cutover-deploy-check.mjs
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

284 lines
8.9 KiB
JavaScript

#!/usr/bin/env node
/**
* Read-only cutover deploy gates (no migrate/up, no restarts, no infra changes).
*
* Checks:
* 1) goose status includes applied 039_worker_heartbeats + 040_job_hotpath_indexes + 041_password_reset_tokens + 042_user_session_version
* 2) GET /readyz reports checks.worker=ok (fresh processing heartbeat <=60s)
* 3) adapter-node host gates (pin, svelte.config import, build/start scripts)
*
* Usage:
* node scripts/cutover-deploy-check.mjs
* npm run cutover:deploy-check
* npm run cutover:deploy-check:code
* node scripts/cutover-deploy-check.mjs --skip-goose
* node scripts/cutover-deploy-check.mjs --skip-readyz
* HEALTH_BASE_URL=https://api.example.com node scripts/cutover-deploy-check.mjs
*/
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { DEV_API_PORT, DEV_PUBLIC_API_URL } from "./dev-ports.mjs";
import { loadRootEnv, repoRoot } from "./root-env.mjs";
const REQUIRED_MIGRATIONS = [
"039_worker_heartbeats",
"040_job_hotpath_indexes",
"041_password_reset_tokens",
"042_user_session_version",
];
const ADAPTER_NODE_PIN = "5.5.7";
const args = new Set(process.argv.slice(2));
const skipGoose = args.has("--skip-goose");
const skipReadyz = args.has("--skip-readyz");
const wantHelp = args.has("-h") || args.has("--help");
if (wantHelp) {
console.log(`Usage: node scripts/cutover-deploy-check.mjs [--skip-goose] [--skip-readyz]
Read-only cutover gates for goose 039+040+041+042, worker /readyz heartbeat, and
adapter-node host gates (pin + svelte.config + build/start scripts). Does not
run goose up, restart processes, or change infra.
Env: DATABASE_URL (goose), PUBLIC_API_URL / HEALTH_BASE_URL (readyz).
Alias: npm run cutover:deploy-check
npm run cutover:deploy-check:code (--skip-goose --skip-readyz)`);
process.exit(0);
}
const env = loadRootEnv(process.env);
const apiDir = path.join(repoRoot, "apps", "api");
const failures = [];
function section(title) {
console.log(`\n==> ${title}`);
}
function pass(msg) {
console.log(`PASS ${msg}`);
}
function fail(msg) {
console.log(`FAIL ${msg}`);
failures.push(msg);
}
function info(msg) {
console.log(`NOTE ${msg}`);
}
function parseReadyz(text) {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function migrationApplied(statusText, id) {
const lineMatch = statusText
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.includes(id));
if (lineMatch.length === 0) return { found: false, pending: false };
const pending = lineMatch.every((l) => /\bPending\b/i.test(l));
if (pending) return { found: true, pending: true };
return { found: true, pending: false };
}
function checkGoose() {
section("goose status (039 + 040 + 041 + 042)");
if (skipGoose) {
info("skipped (--skip-goose)");
return;
}
if (!env.DATABASE_URL) {
fail("DATABASE_URL unset - cannot verify goose 039/040/041/042 (set root .env or shell)");
return;
}
const r = spawnSync(
"go",
[
"run",
"github.com/pressly/goose/v3/cmd/goose@v3.24.3",
"-dir",
"sql/schema",
"postgres",
env.DATABASE_URL,
"status",
],
{
cwd: apiDir,
env,
encoding: "utf8",
shell: process.platform === "win32",
},
);
if (r.error) {
fail(`goose status spawn failed: ${r.error.message}`);
return;
}
const out = `${r.stdout || ""}${r.stderr || ""}`;
if (r.status !== 0) {
fail(`goose status exited ${r.status}`);
if (out.trim()) console.log(out.trim());
return;
}
console.log(out.trim() || "(empty goose status)");
for (const id of REQUIRED_MIGRATIONS) {
const st = migrationApplied(out, id);
if (!st.found) {
fail(`migration ${id} not listed in goose status`);
} else if (st.pending) {
fail(`migration ${id} is Pending - run npm run migrate (ops window), then re-check`);
} else {
pass(`${id} applied`);
}
}
}
async function checkReadyz() {
section("/readyz worker heartbeat");
if (skipReadyz) {
info("skipped (--skip-readyz)");
return;
}
const base = (
env.HEALTH_BASE_URL ||
env.PUBLIC_API_URL ||
DEV_PUBLIC_API_URL ||
`http://127.0.0.1:${DEV_API_PORT}`
).replace(/\/$/, "");
const url = `${base}/readyz`;
let res;
let text;
try {
res = await fetch(url);
text = await res.text();
} catch (err) {
fail(
`GET ${url} failed: ${err instanceof Error ? err.message : err} (start api+worker; npm run dev)`,
);
return;
}
console.log(`GET ${url} -> ${res.status}`);
console.log(text);
const body = parseReadyz(text);
const worker = body?.checks?.worker;
const age = body?.worker_last_seen_age_s;
if (res.status === 200 && worker === "ok") {
pass(
`worker heartbeat ok` +
(typeof age === "number" ? ` (age_s=${age}, stale after 60s)` : ""),
);
return;
}
if (res.status === 503 && (worker === "missing" || worker === "stale")) {
fail(
`checks.worker=${worker} - restart cmd/worker after goose 039+040+041+042 (API-only -> 503 is expected)`,
);
return;
}
fail(
`/readyz not ready (status=${res.status}, checks.worker=${worker ?? "n/a"})`,
);
}
function checkAdapterNodeNotes() {
section("adapter-node host notes (ops)");
const pkgPath = path.join(repoRoot, "apps", "web", "package.json");
const svelteCfgPath = path.join(repoRoot, "apps", "web", "svelte.config.js");
const rootPkgPath = path.join(repoRoot, "package.json");
let pkg = null;
let rootPkg = null;
try {
pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
} catch (err) {
fail(`could not read apps/web/package.json: ${err instanceof Error ? err.message : err}`);
return;
}
const pin =
pkg?.dependencies?.["@sveltejs/adapter-node"] ??
pkg?.devDependencies?.["@sveltejs/adapter-node"] ??
null;
if (pin === ADAPTER_NODE_PIN) {
pass(`@sveltejs/adapter-node@${pin} pinned in apps/web/package.json`);
} else {
fail(
`@sveltejs/adapter-node pin is ${JSON.stringify(pin)} (expected ${ADAPTER_NODE_PIN})`,
);
}
try {
const svelteCfg = fs.readFileSync(svelteCfgPath, "utf8");
if (/from\s+["']@sveltejs\/adapter-node["']/.test(svelteCfg)) {
pass("apps/web/svelte.config.js imports @sveltejs/adapter-node");
} else {
fail("apps/web/svelte.config.js does not import @sveltejs/adapter-node");
}
} catch (err) {
fail(`could not read apps/web/svelte.config.js: ${err instanceof Error ? err.message : err}`);
}
const webBuild = pkg?.scripts?.build;
const webStart = pkg?.scripts?.start;
if (typeof webBuild === "string" && webBuild.trim()) {
pass(`apps/web build script: ${webBuild}`);
} else {
fail("apps/web package.json missing scripts.build");
}
if (webStart === "node build") {
pass("apps/web start script: node build (adapter-node output)");
} else {
fail(
`apps/web scripts.start is ${JSON.stringify(webStart)} (expected "node build")`,
);
}
try {
rootPkg = JSON.parse(fs.readFileSync(rootPkgPath, "utf8"));
} catch (err) {
fail(`could not read root package.json: ${err instanceof Error ? err.message : err}`);
}
if (rootPkg) {
if (rootPkg?.scripts?.["start:web"] === "npm run start --workspace=web") {
pass("root start:web -> npm run start --workspace=web");
} else {
fail(
`root scripts.start:web is ${JSON.stringify(rootPkg?.scripts?.["start:web"])} (expected "npm run start --workspace=web")`,
);
}
if (rootPkg?.scripts?.["cutover:deploy-check"] === "node scripts/cutover-deploy-check.mjs") {
pass("root cutover:deploy-check script present");
} else {
fail("root package.json missing scripts[\"cutover:deploy-check\"]");
}
if (
rootPkg?.scripts?.["cutover:deploy-check:code"] ===
"node scripts/cutover-deploy-check.mjs --skip-goose --skip-readyz"
) {
pass("root cutover:deploy-check:code script present");
} else {
fail("root package.json missing scripts[\"cutover:deploy-check:code\"]");
}
}
info("Host still required: npm run build --workspace=web -> npm run start:web (or node build in apps/web).");
info("Point WEB_ORIGIN / reverse proxy at that Node process; API stays on PUBLIC_API_URL.");
info("Do not deploy as static-CDN-only - hooks.server.ts + server routes need Node.");
info("See docs/production-checklist.md section 0 and docs/production-readiness.md deploy note.");
}
console.log("cutover-deploy-check (read-only; no infra changes)");
checkGoose();
await checkReadyz();
checkAdapterNodeNotes();
section("summary");
if (failures.length === 0) {
console.log("ok - goose 039+040+041+042 + worker heartbeat gates green; adapter-node host gates green.");
console.log(
"Reminder: this does not clear full Prod GO (Clerk/SMTP/Stripe/DNS/orphan run still ops).",
);
process.exit(0);
}
console.log(`${failures.length} failure(s):`);
for (const f of failures) console.log(` - ${f}`);
process.exit(1);