flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/scripts/deploy/stack.mjs

397 lines16,729 bytesCodeBlame
1// The deploy manifest (deploy/stack.jsonc) and what it implies: each
2// unit's Wrangler config, the shared crates and packages it is built from,
3// and which units a set of changed files touches. Pure apart from reading
4// files and `cargo metadata`, so the tests can drive it.
5
6import { execFileSync } from "node:child_process";
7import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
8import { dirname, join, relative } from "node:path";
9import { fileURLToPath } from "node:url";
10
11export const ROOT = join(dirname(fileURLToPath(import.meta.url)), "../..");
12export const STACK_FILE = "deploy/stack.jsonc";
13export const KINDS = ["rust-worker", "ts-worker", "react-router", "astro"];
14export const SELF_HOST = ["run", "off", "separate", "none"];
15
16/** Strips comments and trailing commas from JSONC. Strings are respected. */
17export function parseJsonc(text) {
18 let result = "";
19 let inString = false;
20 for (let i = 0; i < text.length; i++) {
21 const char = text[i];
22 if (inString) {
23 result += char;
24 if (char === "\\") result += text[++i];
25 else if (char === '"') inString = false;
26 } else if (char === '"') {
27 inString = true;
28 result += char;
29 } else if (char === "/" && text[i + 1] === "/") {
30 while (i < text.length && text[i] !== "\n") i++;
31 result += "\n";
32 } else if (char === "/" && text[i + 1] === "*") {
33 i = text.indexOf("*/", i + 2) + 1;
34 } else {
35 result += char;
36 }
37 }
38 return JSON.parse(result.replace(/,(\s*[}\]])/g, "$1"));
39}
40
41const readJsonc = (path) => parseJsonc(readFileSync(path, "utf8"));
42const posix = (path) => path.replaceAll("\\", "/");
43
44/**
45 * The manifest, each unit with its Wrangler config beside it.
46 * Units keep the manifest's order.
47 */
48export function loadStack(root = ROOT) {
49 const raw = readJsonc(join(root, STACK_FILE));
50 const units = Object.entries(raw.units).map(([id, unit]) => {
51 const configPath = join(root, unit.path, "wrangler.jsonc");
52 const config = existsSync(configPath) ? readJsonc(configPath) : null;
53 return {
54 id,
55 secrets: [],
56 setup: [],
57 inputs: [],
58 ...unit,
59 config,
60 bindsTo: (config?.services ?? []).map((binding) => binding.service),
61 };
62 });
63 return { stages: raw.stages, resources: raw.resources ?? {}, units };
64}
65
66/** The deployable stages (every stage but migrations), in order. */
67export const codeStages = (stack) => stack.stages.filter((stage) => stage !== "migrations");
68
69/**
70 * Workspace crates: name -> { dir, deps: [names of workspace crates it
71 * depends on as a normal or build dependency] }. From `cargo metadata
72 * --no-deps`, which reads only the workspace's manifests.
73 */
74export function cargoWorkspace(root = ROOT, metadata = null) {
75 const meta =
76 metadata ??
77 JSON.parse(
78 execFileSync("cargo", ["metadata", "--no-deps", "--format-version", "1", "--offline"], {
79 cwd: root,
80 encoding: "utf8",
81 maxBuffer: 64 * 1024 * 1024,
82 }),
83 );
84 const crates = new Map();
85 for (const pkg of meta.packages) {
86 crates.set(pkg.name, {
87 dir: posix(relative(meta.workspace_root ?? root, dirname(pkg.manifest_path))),
88 deps: [],
89 raw: pkg,
90 });
91 }
92 for (const crate of crates.values()) {
93 crate.deps = crate.raw.dependencies
94 // Dev-dependencies are not in what deploys.
95 .filter((dep) => dep.kind !== "dev" && dep.path)
96 .map((dep) => dep.name)
97 .filter((name) => crates.has(name));
98 delete crate.raw;
99 }
100 return crates;
101}
102
103/**
104 * npm workspace packages: name -> { dir, deps: [workspace package names] }.
105 * dependencies and devDependencies both count: a build reads either.
106 */
107export function npmWorkspace(root = ROOT) {
108 const rootPkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
109 const dirs = [];
110 for (const pattern of rootPkg.workspaces ?? []) {
111 if (pattern.endsWith("/*")) {
112 const parent = pattern.slice(0, -2);
113 if (!existsSync(join(root, parent))) continue;
114 for (const name of readdirSync(join(root, parent)).sort()) {
115 if (existsSync(join(root, parent, name, "package.json"))) dirs.push(`${parent}/${name}`);
116 }
117 } else if (existsSync(join(root, pattern, "package.json"))) {
118 dirs.push(pattern);
119 }
120 }
121 const packages = new Map();
122 const declared = new Map();
123 for (const dir of dirs) {
124 const pkg = JSON.parse(readFileSync(join(root, dir, "package.json"), "utf8"));
125 packages.set(pkg.name, { dir, deps: [] });
126 declared.set(pkg.name, Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }));
127 }
128 for (const [name, pkg] of packages) pkg.deps = declared.get(name).filter((dep) => packages.has(dep));
129 return packages;
130}
131
132/** Every name reachable from `start` through `graph` (start excluded). */
133function closure(graph, start) {
134 const seen = new Set();
135 const stack = [...(graph.get(start)?.deps ?? [])];
136 while (stack.length) {
137 const name = stack.pop();
138 if (seen.has(name)) continue;
139 seen.add(name);
140 stack.push(...(graph.get(name)?.deps ?? []));
141 }
142 return [...seen];
143}
144
145/** Files at the root every unit of a kind is built with. */
146export function globalInputs(kind) {
147 const inputs = ["package.json"];
148 if (kind === "rust-worker") inputs.push("Cargo.toml", "Cargo.lock", "scripts/build-rust-worker.mjs");
149 // A Rust worker's JavaScript is a small shim worker-build bundles itself,
150 // so the npm lockfile only changes what npm-built units ship.
151 else inputs.push("package-lock.json", "tsconfig.base.json");
152 return inputs;
153}
154
155/**
156 * Adds to each unit what it is built from:
157 * crate / pkg: its own crate or package name, when it has one;
158 * dependsOn: folders of the shared crates and packages it uses;
159 * inputs: the manifest's own inputs plus the root files its kind uses;
160 * image: for a Containers image, the folders and files it is built from
161 * (`dirs`, `files`), and its base's folder (`baseDirs`).
162 */
163export function resolveStack(stack, { cargo, npm }) {
164 const crateByDir = new Map([...cargo].map(([name, crate]) => [crate.dir, name]));
165 const pkgByDir = new Map([...npm].map(([name, pkg]) => [pkg.dir, name]));
166 for (const unit of stack.units) {
167 const crate = crateByDir.get(unit.path) ?? null;
168 const pkg = pkgByDir.get(unit.path) ?? null;
169 const dirs = new Set();
170 if (crate) for (const name of closure(cargo, crate)) dirs.add(cargo.get(name).dir);
171 if (pkg) for (const name of closure(npm, pkg)) dirs.add(npm.get(name).dir);
172 dirs.delete(unit.path);
173 unit.crate = crate;
174 unit.pkg = pkg;
175 unit.dependsOn = [...dirs].sort();
176 unit.inputs = [...new Set([...(unit.inputs ?? []), ...globalInputs(unit.kind)])].sort();
177 if (unit.image) {
178 const name = unit.image.crate;
179 const crates = name && cargo.has(name) ? [name, ...closure(cargo, name)] : [];
180 // The image is the base (recorded in its lock) plus the binary: a
181 // change to the base's folder reaches the image only through a new
182 // lock, which `build-base` writes.
183 const lock = unit.image.base?.lock;
184 unit.image = {
185 ...unit.image,
186 dirs: crates.map((c) => cargo.get(c).dir).sort(),
187 files: [unit.image.dockerfile, ...(lock ? [lock] : []), "Cargo.toml", "Cargo.lock", "scripts/build-runner.mjs"],
188 baseDirs: unit.image.base ? [unit.image.base.context] : [],
189 };
190 for (const dir of unit.image.dirs) if (dir !== unit.path) unit.dependsOn.push(dir);
191 unit.dependsOn = [...new Set(unit.dependsOn)].sort();
192 unit.inputs = [...new Set([...unit.inputs, "Cargo.toml", "Cargo.lock", "scripts/build-runner.mjs"])].sort();
193 }
194 }
195 return stack;
196}
197
198/** The manifest, resolved against this checkout. */
199export function resolvedStack(root = ROOT) {
200 return resolveStack(loadStack(root), { cargo: cargoWorkspace(root), npm: npmWorkspace(root) });
201}
202
203const under = (file, dir) => file === dir || file.startsWith(`${dir}/`);
204
205/**
206 * Why a set of changed files (repository-relative, `/`-separated) touches
207 * a unit: the first file that does, and through what. Null if none does.
208 */
209export function touches(unit, files) {
210 for (const file of files) {
211 if (under(file, unit.path)) return { file, via: "its own folder" };
212 }
213 for (const file of files) {
214 const dir = unit.dependsOn.find((d) => under(file, d));
215 if (dir) return { file, via: dir };
216 if (unit.inputs.includes(file)) return { file, via: file };
217 }
218 return null;
219}
220
221/** Whether changed files touch a unit's Containers image. */
222export function touchesImage(unit, files) {
223 if (!unit.image) return false;
224 return files.some((file) => unit.image.files.includes(file) || unit.image.dirs.some((dir) => under(file, dir)));
225}
226
227/** Whether changed files touch the folder a unit's base image is built from. */
228export function touchesBase(unit, files) {
229 return Boolean(unit.image?.baseDirs?.length) && files.some((file) => unit.image.baseDirs.some((dir) => under(file, dir)));
230}
231
232/** Units named on the command line: short names, folders or Worker names. */
233export function pick(stack, names) {
234 const wanted = names.flatMap((name) => name.split(",")).map((name) => name.trim().replace(/\/$/, "")).filter(Boolean);
235 const found = [];
236 for (const name of wanted) {
237 const unit = stack.units.find((u) => u.id === name || u.path === name || u.worker === name);
238 if (!unit) throw new Error(`No unit called ${name}. Units: ${stack.units.map((u) => u.id).join(", ")}`);
239 if (!found.includes(unit)) found.push(unit);
240 }
241 return found;
242}
243
244/** Units grouped by stage, in stage order, keeping the manifest's order inside each. */
245export function byStage(stack, units) {
246 return codeStages(stack)
247 .map((stage) => ({ stage, units: units.filter((u) => u.stage === stage) }))
248 .filter((group) => group.units.length);
249}
250
251/** The most Rust workers one CI job builds; more are split across jobs. */
252export const RUST_PER_JOB = 4;
253
254/**
255 * How a stage's units are split into CI jobs, so units that share a build
256 * share a sandbox: Rust workers (one Cargo target, at most RUST_PER_JOB to
257 * a job, each on a 4-vCPU machine), the TypeScript Workers (cheap),
258 * each site that runs a framework build, and each unit whose Containers
259 * image must be rebuilt (`images`: ids), which needs Docker.
260 */
261export function buildGroups(units, images = []) {
262 const groups = new Map();
263 const add = (key, id) => {
264 if (!groups.has(key)) groups.set(key, []);
265 groups.get(key).push(id);
266 };
267 const rust = units.filter((u) => u.kind === "rust-worker");
268 const shards = Math.ceil(rust.length / RUST_PER_JOB);
269 rust.forEach((unit, i) => add(shards > 1 ? `rust-${(i % shards) + 1}` : "rust", unit.id));
270 for (const unit of units.filter((u) => u.kind !== "rust-worker")) {
271 add(images.includes(unit.id) ? `${unit.id}-image` : unit.kind === "ts-worker" ? "ts" : unit.id, unit.id);
272 }
273 return [...groups].map(([group, ids]) => ({ group, units: ids.join(","), rust: group.startsWith("rust") }));
274}
275
276/**
277 * What is wrong with the manifest, as sentences. Empty when it is right.
278 * `wranglerConfigs`: every wrangler.jsonc in the repository (relative paths).
279 */
280export function problems(stack, wranglerConfigs = findWranglerConfigs()) {
281 const out = [];
282 const stages = codeStages(stack);
283 if (stack.stages[0] !== "migrations") out.push('The first stage is "migrations".');
284 const byWorker = new Map();
285 for (const unit of stack.units) {
286 const where = `Unit ${unit.id}`;
287 if (!KINDS.includes(unit.kind)) out.push(`${where}: kind is one of ${KINDS.join(", ")}.`);
288 if (!stages.includes(unit.stage)) out.push(`${where}: stage is one of ${stages.join(", ")}.`);
289 if (!SELF_HOST.includes(unit.self_host)) out.push(`${where}: self_host is one of ${SELF_HOST.join(", ")}.`);
290 if (!unit.config) {
291 out.push(`${where}: ${unit.path}/wrangler.jsonc does not exist.`);
292 continue;
293 }
294 if (unit.config.name !== unit.worker) out.push(`${where}: worker is ${unit.config.name} in its wrangler.jsonc, not ${unit.worker}.`);
295 byWorker.set(unit.worker, unit);
296 const dbs = (unit.config.d1_databases ?? []).filter((db) => db.migrations_dir);
297 const d1 = dbs[0] ? { database: dbs[0].database_name, migrations: dbs[0].migrations_dir } : null;
298 if (dbs.length > 1) out.push(`${where}: more than one D1 database with migrations; the deploy tool applies one per unit.`);
299 if (JSON.stringify(d1) !== JSON.stringify(unit.d1 ?? null)) {
300 out.push(`${where}: d1 is ${JSON.stringify(d1)} in its wrangler.jsonc, not ${JSON.stringify(unit.d1 ?? null)}.`);
301 }
302 const building = unit.config.build?.command ?? "";
303 if (unit.kind === "rust-worker" && !building.includes("scripts/build-rust-worker.mjs")) {
304 out.push(`${where}: a Rust worker builds with node ../../scripts/build-rust-worker.mjs, not "${building}".`);
305 }
306 if (unit.kind !== "rust-worker" && building) out.push(`${where}: only Rust workers have a build command; ${unit.kind} builds in the deploy tool.`);
307 for (const id of (unit.config.kv_namespaces ?? []).map((kv) => kv.id)) {
308 if (!stack.resources.kv?.[id]) out.push(`${where}: KV namespace ${id} has no name under resources.kv.`);
309 }
310 const hasImage = (unit.config.containers ?? []).some((c) => !String(c.image).includes("registry"));
311 if (hasImage !== Boolean(unit.image)) out.push(`${where}: image is set exactly when its wrangler.jsonc builds a Containers image.`);
312 }
313 const listed = new Set(stack.units.map((u) => posix(join(u.path, "wrangler.jsonc"))));
314 for (const config of wranglerConfigs) {
315 if (!listed.has(config)) out.push(`${config} is not in ${STACK_FILE}: add its unit.`);
316 }
317 // Stage order follows bindings: a unit binds only to units that ship in
318 // its stage or before it.
319 for (const unit of stack.units) {
320 for (const target of unit.bindsTo) {
321 const other = byWorker.get(target);
322 if (!other) {
323 out.push(`Unit ${unit.id} binds to ${target}, which no unit deploys.`);
324 } else if (stages.indexOf(other.stage) > stages.indexOf(unit.stage)) {
325 out.push(`Unit ${unit.id} (${unit.stage}) binds to ${other.id} (${other.stage}), which ships after it.`);
326 }
327 }
328 }
329 return out;
330}
331
332const SKIP_DIRS = new Set(["node_modules", "target", ".git", "build", "dist", ".wrangler", ".generated", ".astro"]);
333
334/** Every wrangler.jsonc or wrangler.toml checked into the repository. */
335export function findWranglerConfigs(root = ROOT) {
336 const found = [];
337 const walk = (dir) => {
338 for (const name of readdirSync(join(root, dir))) {
339 if (SKIP_DIRS.has(name) || name.startsWith(".")) continue;
340 const path = dir ? `${dir}/${name}` : name;
341 if (statSync(join(root, path)).isDirectory()) walk(path);
342 else if (/^wrangler\.(jsonc?|toml)$/.test(name)) found.push(path);
343 }
344 };
345 walk("");
346 return found.sort();
347}
348
349/**
350 * Relative imports in a unit's non-test sources that leave its folder:
351 * each { file, target }. The manifest must cover each one, through a
352 * workspace dependency or `inputs`.
353 */
354export function outsideImports(root, unit) {
355 const found = [];
356 const pattern = /(?:from\s+|import\s*\(\s*|import\s+)["'](\.{1,2}\/[^"']+)["']/g;
357 const walk = (dir) => {
358 for (const name of readdirSync(join(root, dir))) {
359 if (SKIP_DIRS.has(name) || name.startsWith(".")) continue;
360 const path = `${dir}/${name}`;
361 if (statSync(join(root, path)).isDirectory()) {
362 walk(path);
363 continue;
364 }
365 if (!/\.(m?[jt]sx?|astro)$/.test(name) || /\.test\.[jt]sx?$/.test(name) || name.endsWith(".d.ts")) continue;
366 const text = readFileSync(join(root, path), "utf8");
367 for (const match of text.matchAll(pattern)) {
368 const target = posix(join(dirname(path), match[1]));
369 if (!under(target, unit.path)) found.push({ file: path, target });
370 }
371 }
372 };
373 walk(unit.path);
374 return found;
375}
376
377/**
378 * npm ci of only what the units need: Wrangler alone for Rust workers,
379 * and each npm-built unit's workspace with the packages it uses. A CI job
380 * that deploys three Rust workers does not install the site's toolchain.
381 */
382export function npmCiArgs(units, npm) {
383 const workspaces = new Set();
384 for (const unit of units) {
385 if (!unit.pkg) continue;
386 const stack = [unit.pkg];
387 while (stack.length) {
388 const name = stack.pop();
389 if (workspaces.has(name)) continue;
390 workspaces.add(name);
391 stack.push(...(npm.get(name)?.deps ?? []));
392 }
393 }
394 const base = ["ci", "--no-audit", "--no-fund"];
395 if (!workspaces.size) return [...base, "--workspaces=false"];
396 return [...base, "--include-workspace-root", ...[...workspaces].sort().flatMap((name) => ["-w", name])];
397}