asdfas
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}