g1t/scripts/deploy/cloudflare.mjs

190 lines7,807 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1// Wrangler, as the deploy tool uses it: reading which commit each Worker
2// runs, D1 migrations, and deploying. Every call runs in the unit's own
3// folder, so Wrangler reads that unit's config (and not a .env at the
4// repository root, which may hold a token meant for something else).
5
6import { spawn } from "node:child_process";
7import { join } from "node:path";
8
9import { ROOT } from "./stack.mjs";
10
11const WRANGLER = join(ROOT, "node_modules/wrangler/bin/wrangler.js");
12export const ACCOUNT_ID = "1e6f2cffa3f445920836e8ebe446bb58";
13
g1t-runner 0.1.0 is released: signed binaries for five platforms at g1t.sh/downloads/runner; the Artifacts checks' results14/**
15 * Headers for Cloudflare's REST and GraphQL APIs, for the ops scripts:
16 * CLOUDFLARE_API_TOKEN as a bearer token, or else a global API key
17 * (CLOUDFLARE_API_KEY with CLOUDFLARE_EMAIL). Null when neither is set.
18 */
19export function cloudflareAuth(env = process.env) {
20 if (env.CLOUDFLARE_API_TOKEN) return { authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}` };
21 if (env.CLOUDFLARE_API_KEY && env.CLOUDFLARE_EMAIL) {
22 return { "x-auth-key": env.CLOUDFLARE_API_KEY, "x-auth-email": env.CLOUDFLARE_EMAIL };
23 }
24 return null;
25}
26
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow27/** What a deploy's version message starts with, followed by the commit. */
28export const MESSAGE_PREFIX = "g1t-deploy";
29
30/**
31 * The environment Wrangler runs with. In CI (CI=true) it is the job's:
32 * CLOUDFLARE_API_TOKEN from the repository's secret. On a laptop it is
33 * your `wrangler login`, unless CLOUDFLARE_DEPLOY_TOKEN is set, as
34 * scripts/deploy.sh always did: a CLOUDFLARE_API_TOKEN or global API key
35 * in your shell is for other tools.
36 */
37export function wranglerEnv(base = process.env) {
38 const env = { ...base, WRANGLER_SEND_METRICS: "false", NO_COLOR: "1", FORCE_COLOR: "0" };
39 env.CLOUDFLARE_ACCOUNT_ID ||= ACCOUNT_ID;
40 if (base.CLOUDFLARE_DEPLOY_TOKEN) {
41 env.CLOUDFLARE_API_TOKEN = base.CLOUDFLARE_DEPLOY_TOKEN;
42 } else if (base.CI !== "true") {
43 env.CLOUDFLARE_API_TOKEN = "";
44 delete env.CLOUDFLARE_API_KEY;
45 delete env.CLOUDFLARE_EMAIL;
46 }
47 return env;
48}
49
50/**
51 * Runs a command; resolves with { code, out } (stdout and stderr together,
Fast pages, required checks on the branch, self-hosted runners, honest incidents52 * in order). `onLine` sees each line as it comes; `input` is written to
53 * its stdin.
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow54 */
Fast pages, required checks on the branch, self-hosted runners, honest incidents55export function exec(command, args, { cwd = ROOT, env = process.env, onLine, shell = false, input } = {}) {
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow56 return new Promise((resolve) => {
57 const child = spawn(command, args, { cwd, env, shell, windowsHide: true });
Fast pages, required checks on the branch, self-hosted runners, honest incidents58 if (input !== undefined) child.stdin.end(input);
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow59 let out = "";
60 let partial = "";
61 const take = (chunk) => {
62 const text = chunk.toString();
63 out += text;
64 if (!onLine) return;
65 const lines = (partial + text).split(/\r?\n/);
66 partial = lines.pop();
67 for (const line of lines) onLine(line);
68 };
69 child.stdout.on("data", take);
70 child.stderr.on("data", take);
71 child.on("error", (error) => resolve({ code: 127, out: `${out}${error.message}\n` }));
72 child.on("close", (code) => {
73 if (onLine && partial) onLine(partial);
74 resolve({ code: code ?? 1, out });
75 });
76 });
77}
78
79/** Runs the repository's own Wrangler in `cwd`. */
80export function wrangler(args, { cwd, env = wranglerEnv(), onLine } = {}) {
81 return exec(process.execPath, [WRANGLER, ...args], { cwd, env, onLine });
82}
83
84/** The first JSON value in Wrangler's output (it may print notices first). */
85export function jsonFrom(out) {
86 const start = out.search(/^[[{]/m);
87 if (start < 0) throw new Error(`no JSON in: ${out.slice(0, 300)}`);
88 return JSON.parse(out.slice(start));
89}
90
91/** The message and tag a deploy of `sha` is annotated with. */
92export function annotation(sha, subject = "") {
93 const message = `${MESSAGE_PREFIX} ${sha} ${subject}`.trim().slice(0, 100);
94 return { message, tag: `g1t-${sha.slice(0, 12)}` };
95}
96
97/** The commit a version message names, or null. Dirty deploys name none. */
98export function commitFrom(message) {
99 const match = new RegExp(`^${MESSAGE_PREFIX} ([0-9a-f]{40})(?:\\s|$)`).exec(message ?? "");
100 return match ? match[1] : null;
101}
102
103/**
104 * Which commit a Worker's live version was deployed from, given Wrangler's
105 * `deployments status --json` and `versions list --json`. A version made by
106 * `wrangler secret put` keeps the code of the one before it, so those are
107 * looked through. Anything else without our message (a deploy by hand, a
108 * dashboard edit) leaves the commit unknown, and the unit is deployed again.
109 */
110export function liveCommit(status, versions) {
111 const live = [...(status.versions ?? [])].sort((a, b) => b.percentage - a.percentage);
112 if (!live.length) return { sha: null, why: "no live version" };
113 const split = live.length > 1 && live[1].percentage > 0;
114 const byNumber = [...versions].sort((a, b) => b.number - a.number);
115 let index = byNumber.findIndex((v) => v.id === live[0].version_id);
116 if (index < 0) return { sha: null, why: "its live version is not among the recent ones", version: live[0].version_id };
117 const version = byNumber[index];
118 while (index < byNumber.length) {
119 const candidate = byNumber[index];
120 const sha = commitFrom(candidate.annotations?.["workers/message"]);
121 if (sha) {
122 return {
123 sha,
124 version: version.id,
125 at: candidate.metadata?.created_on ?? null,
126 by: candidate.metadata?.author_email ?? null,
127 split,
128 why: split ? "a gradual deployment is in progress; its main version is used" : null,
129 };
130 }
131 if (candidate.annotations?.["workers/triggered_by"] !== "secret") break;
132 index++;
133 }
134 return { sha: null, version: version.id, why: "its live version was not deployed by scripts/deploy.mjs" };
135}
136
137/** Reads the commit a unit's Worker runs. Never throws. */
138export async function readLive(unit) {
139 const cwd = join(ROOT, unit.path);
140 const [status, versions] = await Promise.all([
141 wrangler(["deployments", "status", "--name", unit.worker, "--json"], { cwd }),
142 wrangler(["versions", "list", "--name", unit.worker, "--json"], { cwd }),
143 ]);
144 if (status.code !== 0) {
145 if (/not found|does not exist|10007/i.test(status.out)) return { sha: null, missing: true, why: "never deployed" };
146 return { sha: null, error: lastLines(status.out) };
147 }
148 try {
149 return liveCommit(jsonFrom(status.out), versions.code === 0 ? jsonFrom(versions.out) : []);
150 } catch (error) {
151 return { sha: null, error: String(error.message ?? error) };
152 }
153}
154
155/** Migration files Wrangler lists as not yet applied. */
156export function pendingFrom(out) {
157 if (/No migrations to apply/i.test(out)) return [];
158 const names = [...out.matchAll(/([\w.-]+\.sql)\b/g)].map((m) => m[1]);
159 return [...new Set(names)];
160}
161
162/** Pending migrations of a unit's database: { pending } or { error }. */
163export async function pendingMigrations(unit) {
Money in billing's messages reads to the cent, and the deploy reads migrations again after a failure164 const list = () => wrangler(["d1", "migrations", "list", unit.d1.database, "--remote"], { cwd: join(ROOT, unit.path) });
165 // Once more after a failure: Cloudflare's API sometimes answers 403
166 // while Wrangler's login refreshes (seen on 2026-10-06).
167 let found = await list();
168 if (found.code !== 0) found = await list();
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow169 if (found.code !== 0) return { error: lastLines(found.out) };
170 return { pending: pendingFrom(found.out) };
171}
172
173export function applyMigrations(unit, onLine) {
174 return wrangler(["d1", "migrations", "apply", unit.d1.database, "--remote"], { cwd: join(ROOT, unit.path), onLine });
175}
176
177/** The version a deploy made, from Wrangler's output. */
178export function versionFrom(out) {
179 return /Current Version ID:\s*([0-9a-f-]{36})/i.exec(out)?.[1] ?? null;
180}
181
182/** Whether Docker can build here (for a Containers image). */
183export async function dockerAvailable() {
184 const found = await exec("docker", ["info", "--format", "{{.ServerVersion}}"]);
185 return found.code === 0;
186}
187
188export function lastLines(text, count = 12) {
189 return text.trim().split(/\r?\n/).slice(-count).join("\n");
190}