asdfas
0

Configure Feed

Select the types of activity you want to include in your feed.

szzt / scripts / deploy.mjs
7.4 kB 136 lines
1// Deploy the gateway to Railway production WITH a provenance stamp, so `/healthz` reports the exact 2// identity of the running image (deploy-provenance-plan.md). 3// 4// pnpm ship # or: node scripts/deploy.mjs 5// 6// WHY a stamp at all: `railway up --ci` uploads the LOCAL WORKING TREE, not a git ref — `git push` ships 7// nothing. Production therefore runs an unnamed snapshot matching no commit, and you can't tell what's live 8// by reading the repo. The recorded outage was committed-but-UNPUSHED code (production ran ahead of 9// origin/main). So a SHA alone is a trap: a reader sees a SHA, can't find it on the remote, and wrongly 10// concludes "stale." The `pushed` field is the anti-incident field — it discloses whether the reader can 11// resolve the SHA at all. The stamp names a SHA AND whether it's pushed. 12// 13// Sequence (order matters — see the inline comments): 14// 1. Refuse a dirty tree (BEFORE writing the stamp). No --allow-dirty escape hatch by design. 15// 2. Assert build-info.json is NOT gitignored (else it would never reach the uploaded image → /healthz 16// says "unknown" forever). 17// 3. Compute + write apps/gateway/build-info.json. 18// 4. `railway up --ci` (inherited stdio), and ALWAYS unlink the stamp afterward (finally). 19// 5. Poll https://szzt.app/healthz until it reports the deployed commit (best-effort; timeout is a warning). 20import { spawnSync } from "node:child_process"; 21import { writeFileSync, unlinkSync } from "node:fs"; 22import { fileURLToPath } from "node:url"; 23 24const repoRoot = fileURLToPath(new URL("..", import.meta.url)); 25const STAMP_REL = "apps/gateway/build-info.json"; 26const STAMP_ABS = fileURLToPath(new URL("../apps/gateway/build-info.json", import.meta.url)); 27const HEALTHZ = "https://szzt.app/healthz"; 28const POLL_TIMEOUT_MS = 90_000; 29 30// Run a command, capturing stdout; throw with a useful message on non-zero exit. `git`/`railway` only. 31function run(cmd, args, opts = {}) { 32 const r = spawnSync(cmd, args, { cwd: repoRoot, encoding: "utf8", ...opts }); 33 if (r.error) throw r.error; 34 if (r.status !== 0) { 35 throw new Error(`${cmd} ${args.join(" ")} exited ${r.status}\n${r.stderr ?? ""}`); 36 } 37 return (r.stdout ?? "").trim(); 38} 39 40// --- 1. Refuse a dirty tree, BEFORE writing the stamp. ----------------------------------------------- 41// `--porcelain` WITH untracked files (the default) is deliberate: `railway up` uploads tracked AND 42// untracked-not-gitignored files, so weakening this to `-uno` would let uploaded bytes escape the check. 43// A dirty deploy would produce an unnamed snapshot whose /healthz falsely asserts a clean commit — exactly 44// the laundering this stamp exists to prevent. No override flag: under outage pressure a flag becomes the 45// default. If a stale build-info.json from a crashed prior run is present, it surfaces here as untracked 46// and (correctly) blocks until cleaned. 47const dirty = run("git", ["status", "--porcelain"]); 48if (dirty !== "") { 49 console.error("Refusing to deploy: working tree is dirty. Commit or stash first:\n" + dirty); 50 process.exit(1); 51} 52 53// --- 2. Assert the stamp is not gitignored. ---------------------------------------------------------- 54// `git check-ignore <path>` exits 0 if the path IS ignored, 1 if it is NOT. We require NOT-ignored: a 55// gitignored stamp never gets uploaded, so /healthz would report "unknown" on every deploy forever. 56const checkIgnore = spawnSync("git", ["check-ignore", STAMP_REL], { cwd: repoRoot, encoding: "utf8" }); 57if (checkIgnore.status === 0) { 58 console.error(`Refusing to deploy: ${STAMP_REL} is gitignored, so it would never reach the image. ` + 59 "Remove it from .gitignore (a deploy stamp must be untracked but NOT ignored)."); 60 process.exit(1); 61} 62 63// --- 3. Compute + write the stamp. ------------------------------------------------------------------- 64const commit = run("git", ["rev-parse", "HEAD"]); // full 40-char sha 65const branch = run("git", ["rev-parse", "--abbrev-ref", "HEAD"]); 66 67// `pushed` reads local remote-tracking refs, which go stale without a fetch. Best-effort refresh with a 68// short timeout; proceed on failure. A false negative (says unpushed when it is pushed) is safe — it only 69// over-warns; a false positive is impossible (a ref can't claim to contain HEAD unless it does). 70spawnSync("git", ["fetch", "--quiet"], { cwd: repoRoot, encoding: "utf8", timeout: 10_000 }); 71const remoteContains = spawnSync("git", ["branch", "-r", "--contains", "HEAD"], { cwd: repoRoot, encoding: "utf8" }); 72const pushed = remoteContains.status === 0 && (remoteContains.stdout ?? "").trim() !== ""; 73 74if (!pushed) { 75 // Do NOT refuse — refusing would block the operator behind a push round-trip mid-outage. Unpushed 76 // commits are honestly identified by the stamp; warn and proceed. 77 console.error(`WARNING: HEAD (${commit.slice(0, 12)}) is not on any remote branch. Deploying anyway; ` + 78 "the stamp will record pushed:false so /healthz discloses that this SHA is not resolvable on the remote."); 79} 80 81const stamp = { 82 commit, 83 branch, 84 dirty: false, // the tree was clean at check time (step 1); the script refuses otherwise 85 pushed, 86 builtAt: new Date().toISOString(), 87}; 88writeFileSync(STAMP_ABS, JSON.stringify(stamp, null, 2) + "\n"); 89console.log("Wrote stamp:", JSON.stringify(stamp)); 90 91// --- 4. Upload, and ALWAYS unlink the stamp afterward. ----------------------------------------------- 92// The unlink MUST be in `finally`: a crash between write and unlink would leave an untracked build-info.json 93// that blocks the NEXT deploy's dirty check (step 1). The stamp is a transient deploy artifact, never a 94// tracked file. 95let uploadOk = false; 96try { 97 const r = spawnSync("railway", ["up", "--ci"], { cwd: repoRoot, stdio: "inherit" }); 98 if (r.error) throw r.error; 99 uploadOk = r.status === 0; 100 if (!uploadOk) console.error(`railway up --ci exited ${r.status}`); 101} finally { 102 try { 103 unlinkSync(STAMP_ABS); 104 } catch (e) { 105 if (e?.code !== "ENOENT") console.error("Failed to unlink stamp:", e); 106 } 107} 108 109if (!uploadOk) process.exit(1); 110 111// --- 5. Poll /healthz until the deployed commit shows up. -------------------------------------------- 112// Railway rolls out asynchronously: the OLD container keeps serving /healthz for a bit after `railway up` 113// returns, so we poll for the new commit rather than assuming immediate cutover. A timeout is a WARNING, 114// not a failure — the upload already succeeded; the rollout may simply be slow. 115console.log(`Polling ${HEALTHZ} for commit ${commit.slice(0, 12)} (up to ${POLL_TIMEOUT_MS / 1000}s)...`); 116const deadline = Date.now() + POLL_TIMEOUT_MS; 117let matched = false; 118while (Date.now() < deadline) { 119 try { 120 const res = await fetch(HEALTHZ, { cache: "no-store" }); 121 const body = await res.json().catch(() => ({})); 122 if (body.commit === commit) { 123 matched = true; 124 console.log("Live /healthz now reports the deployed commit:", JSON.stringify(body)); 125 break; 126 } 127 console.log(` still serving ${String(body.commit).slice(0, 12)}; waiting...`); 128 } catch (e) { 129 console.log(" /healthz not answering yet:", e?.message ?? e); 130 } 131 await new Promise((r) => setTimeout(r, 5_000)); 132} 133if (!matched) { 134 console.error(`WARNING: ${HEALTHZ} did not report ${commit.slice(0, 12)} within ${POLL_TIMEOUT_MS / 1000}s. ` + 135 "The upload succeeded; the rollout may still be in progress. Verify manually."); 136}