| 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 | |
| 20 | import { 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 | */ |
| 31 | export 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 | */ |
| 47 | export 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 | |
| 69 | if (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 | } |