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.
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
#!/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);
|
||||
Reference in New Issue
Block a user