Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
284 lines
8.9 KiB
JavaScript
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); |