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