Skip to content
77 linesCodeBlameRaw
1// Tells status.g1t.sh a deploy started or finished, so the restarts it
2// causes are counted but not drafted as incidents (apps/status detect.ts:
3// during the deploy and 3 minutes after; trouble that outlasts that is
4// drafted with its true start).
5//
6// node scripts/deploy/status-window.mjs started [id]
7// node scripts/deploy/status-window.mjs finished [id]
8//
9// Needs STATUS_DEPLOY_TOKEN (the status Worker's secret of the same name);
10// STATUS_URL defaults to https://status.g1t.sh. Never fails a deploy:
11// without the token it does nothing, and any error is a warning.
12//
13// scripts/deploy.mjs `deploy()` wraps its stages in `withDeployWindow`
14// once there is something to ship (never in a dry run), so every deploy,
15// by hand or in g1t Actions (.g1t/workflows/deploy.yml passes the
16// STATUS_DEPLOY_TOKEN secret), says so. Jobs that run at once (a stage's
17// matrix) each say started and finished; the status Worker counts them and
18// the window closes when the last one finishes.
19
20import { pathToFileURL } from "node:url";
21
22/**
23 * Runs `work` inside a deploy window: "started" before, "finished" after,
24 * whether it succeeded or threw. Neither announcement can fail the deploy.
25 *
26 * @template T
27 * @param {() => Promise<T>} work
28 * @param {{ id?: string | null, dryRun?: boolean, announce?: typeof announceDeploy, env?: Record<string, string | undefined>, log?: (line: string) => void }} [options]
29 * @returns {Promise<T>}
30 */
31export async function withDeployWindow(work, { id = null, dryRun = false, announce = announceDeploy, env = process.env, log } = {}) {
32 if (dryRun) return work();
33 const options = { id, env, ...(log ? { log } : {}) };
34 await announce("started", options);
35 try {
36 return await work();
37 } finally {
38 await announce("finished", options);
39 }
40}
41
42/**
43 * @param {"started" | "finished"} phase
44 * @param {{ id?: string | null, env?: Record<string, string | undefined>, fetchImpl?: typeof fetch, log?: (line: string) => void }} [options]
45 * @returns {Promise<boolean>} whether status.g1t.sh took it
46 */
47export async function announceDeploy(phase, { id = null, env = process.env, fetchImpl = fetch, log = (line) => console.warn(line) } = {}) {
48 const token = (env.STATUS_DEPLOY_TOKEN ?? "").trim();
49 if (!token) return false;
50 const base = (env.STATUS_URL || "https://status.g1t.sh").replace(/\/+$/, "");
51 try {
52 const response = await fetchImpl(`${base}/deploys`, {
53 method: "POST",
54 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
55 body: JSON.stringify({ phase, ...(id ? { id: String(id).slice(0, 100) } : {}) }),
56 signal: AbortSignal.timeout(5000),
57 });
58 if (!response.ok) {
59 log(`status: the deploy ${phase} was not recorded (${response.status}); detection may draft restarts.`);
60 return false;
61 }
62 return true;
63 } catch (error) {
64 log(`status: the deploy ${phase} was not recorded (${error instanceof Error ? error.message : String(error)}).`);
65 return false;
66 }
67}
68
69if (import.meta.url === `file://${process.argv[1]?.replace(/\\/g, "/").replace(/^(?=[A-Za-z]:)/, "/")}`) {
70 const [phase, id] = process.argv.slice(2);
71 if (phase !== "started" && phase !== "finished") {
72 console.error("usage: node scripts/deploy/status-window.mjs started|finished [id]");
73 process.exit(2);
74 }
75 const ok = await announceDeploy(phase, { id: id ?? null });
76 console.log(ok ? `status: deploy ${phase}` : "status: not recorded (no STATUS_DEPLOY_TOKEN, or status did not answer)");
77}