Skip to content
220 linesCodeBlameRaw
1// What a deploy would do: for each unit, the commit it runs, what changed
2// since, and its pending migrations. Git and Wrangler are passed in, so the
3// tests can give their own.
4
5import { execFileSync } from "node:child_process";
6
7import { changedNames, lockRoots, parseCargoLock, parseNpmLock, reaches } from "./lockfiles.mjs";
8import { ROOT, byStage, buildGroups, codeStages, testOnlySource, touches, touchesImage } from "./stack.mjs";
9
10/** Git, read-only. */
11export const git = {
12 head: () => run(["rev-parse", "HEAD"]).trim(),
13 resolve: (rev) => run(["rev-parse", "--verify", `${rev}^{commit}`]).trim(),
14 subject: () => run(["log", "-1", "--format=%s"]).trim(),
15 /** Whether a commit is in this checkout's history. */
16 has: (sha) => {
17 try {
18 run(["cat-file", "-e", `${sha}^{commit}`]);
19 return true;
20 } catch {
21 return false;
22 }
23 },
24 /** A file's text at a commit, or "" if it is not there. */
25 show: (sha, path) => {
26 try {
27 return run(["show", `${sha}:${path}`]);
28 } catch {
29 return "";
30 }
31 },
32 /** The files directly in a folder at a commit (repository-relative). */
33 list: (sha, dir) => {
34 try {
35 return run(["ls-tree", "--name-only", sha, "--", `${dir}/`]).split("\n").filter(Boolean);
36 } catch {
37 return [];
38 }
39 },
40 /** Files changed between two commits. */
41 changed: (from, to) => run(["diff", "--name-only", "--no-renames", from, to]).split("\n").filter(Boolean),
42 /** Whether `older` is in `newer`'s history (and not the same commit). */
43 isAncestor: (older, newer) => {
44 if (older === newer) return false;
45 try {
46 run(["merge-base", "--is-ancestor", older, newer]);
47 return true;
48 } catch {
49 return false;
50 }
51 },
52 /** Uncommitted and untracked files (not ignored ones). */
53 dirty: () =>
54 run(["status", "--porcelain", "--untracked-files=all"])
55 .split("\n")
56 .filter(Boolean)
57 .map((line) => line.slice(3).replace(/^"|"$/g, "").split(" -> ").pop()),
58};
59
60function run(args) {
61 return execFileSync("git", args, { cwd: ROOT, encoding: "utf8", maxBuffer: 256 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
62}
63
64/**
65 * Decides, for each unit in `units`, whether it deploys and why.
66 *
67 * live: unit id -> { sha, why? } (what readLive found)
68 * head: the commit being deployed
69 * force: deploy even what has not changed
70 * gitApi: { has, changed }
71 *
72 * Each unit gets { deploy, reason, since, files, image }: `files` are the
73 * changed files that touch it; `image` says whether its Containers image
74 * must be built.
75 */
76export function decide(units, { live, head, force = false, rollback = false, gitApi = git }) {
77 const diffs = new Map();
78 const changedSince = (sha) => {
79 if (!diffs.has(sha)) diffs.set(sha, gitApi.changed(sha, head));
80 return diffs.get(sha);
81 };
82 // A lockfile change counts for a unit only if a package it uses changed.
83 const locks = new Map();
84 const lockChange = (sha, file) => {
85 const key = `${sha}:${file}`;
86 if (!locks.has(key)) {
87 const parse = file === "Cargo.lock" ? parseCargoLock : parseNpmLock;
88 const after = parse(gitApi.show(head, file));
89 locks.set(key, { after, names: changedNames(parse(gitApi.show(sha, file)), after) });
90 }
91 return locks.get(key);
92 };
93 // A Rust source compiled only for tests (`#[cfg(test)] mod tests;`) is
94 // not in what deploys. Read at the commit being deployed, each file once.
95 const texts = new Map();
96 const listings = new Map();
97 const atHead = {
98 read: (path) => {
99 if (!texts.has(path)) texts.set(path, gitApi.show(head, path));
100 return texts.get(path);
101 },
102 list: (dir) => {
103 if (!listings.has(dir)) listings.set(dir, gitApi.list?.(head, dir) ?? []);
104 return listings.get(dir);
105 },
106 };
107 const testOnly = new Map();
108 const onlyForTests = (unit, file) => {
109 if (!file.endsWith(".rs") || !unit.crateDirs?.some((dir) => file.startsWith(`${dir}/src/`))) return false;
110 if (!testOnly.has(file)) testOnly.set(file, testOnlySource(file, unit.crateDirs, atHead));
111 return testOnly.get(file);
112 };
113 const relevant = (unit, sha, files) =>
114 files.filter((file) => {
115 if (onlyForTests(unit, file)) return false;
116 if (file !== "Cargo.lock" && file !== "package-lock.json") return true;
117 const { after, names } = lockChange(sha, file);
118 const roots = lockRoots(unit)[file === "Cargo.lock" ? "cargo" : "npm"];
119 return reaches(after, roots, names);
120 });
121 return units.map((unit) => {
122 const found = live[unit.id] ?? { sha: null, why: "not read" };
123 const decision = { unit, since: found.sha, deploy: false, reason: "", files: [], image: false };
124 if (!found.sha) {
125 decision.deploy = true;
126 decision.reason = found.error ? `could not read what it runs: ${firstLine(found.error)}` : `no known commit (${found.why ?? "unknown"})`;
127 decision.image = Boolean(unit.image);
128 return decision;
129 }
130 if (found.sha === head) {
131 decision.deploy = force;
132 decision.reason = force ? "forced; already at this commit" : "up to date";
133 return decision;
134 }
135 // What runs is newer than this commit: deploying would roll it back
136 // (a re-run of an old workflow run, say). Never by accident: only with
137 // --rollback, whatever --force says.
138 if (gitApi.has(found.sha) && gitApi.isAncestor?.(head, found.sha)) {
139 decision.deploy = rollback;
140 decision.reason = rollback
141 ? `rolling back from ${found.sha.slice(0, 12)}`
142 : `runs ${found.sha.slice(0, 12)}, which is newer than this commit; deploying would roll it back (pass --rollback to mean it)`;
143 decision.image = rollback && Boolean(unit.image);
144 return decision;
145 }
146 if (!gitApi.has(found.sha)) {
147 decision.deploy = true;
148 decision.reason = `runs ${found.sha.slice(0, 12)}, which this checkout does not have (fetch full history)`;
149 decision.image = Boolean(unit.image);
150 return decision;
151 }
152 const files = relevant(unit, found.sha, changedSince(found.sha));
153 const hit = touches(unit, files);
154 decision.files = hit ? files.filter((file) => touches(unit, [file])) : [];
155 decision.image = touchesImage(unit, files);
156 if (hit) {
157 decision.deploy = true;
158 decision.reason = `${decision.files.length} changed file${decision.files.length === 1 ? "" : "s"} (${hit.file}${hit.via === "its own folder" ? "" : ` via ${hit.via}`})`;
159 } else {
160 decision.deploy = force;
161 decision.reason = force ? "forced; nothing it is built from changed" : "nothing it is built from changed";
162 }
163 return decision;
164 });
165}
166
167const firstLine = (text) => String(text).trim().split("\n").find((line) => /error|\[ERROR\]|X /i.test(line)) ?? String(text).trim().split("\n")[0];
168
169/**
170 * The plan as data, for `plan --json` and the workflow: the migrations to
171 * apply, and each stage's units split into the jobs that build them.
172 */
173export function planJson(stack, decisions, migrations, head) {
174 const deploying = decisions.filter((d) => d.deploy).map((d) => d.unit);
175 const stages = {};
176 for (const stage of codeStages(stack)) {
177 const units = deploying.filter((u) => u.stage === stage);
178 stages[stage] = { units: units.map((u) => u.id), jobs: buildGroups(units, decisions.filter((d) => d.deploy && d.image).map((d) => d.unit.id)) };
179 }
180 return {
181 commit: head,
182 migrations: Object.entries(migrations)
183 .filter(([, m]) => m.pending?.length)
184 .map(([id, m]) => ({ unit: id, database: stack.units.find((u) => u.id === id).d1.database, pending: m.pending })),
185 migration_errors: Object.entries(migrations)
186 .filter(([, m]) => m.error)
187 .map(([id, m]) => ({ unit: id, error: m.error })),
188 stages,
189 units: decisions.map((d) => ({
190 unit: d.unit.id,
191 stage: d.unit.stage,
192 deploy: d.deploy,
193 reason: d.reason,
194 live_commit: d.since,
195 image: d.image,
196 })),
197 stage_order: byStage(stack, deploying).map((g) => g.stage),
198 };
199}
200
201/** A plain table. */
202export function table(rows, headers) {
203 const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i] ?? "").length)));
204 const line = (cells) => cells.map((c, i) => String(c ?? "").padEnd(widths[i])).join(" ").trimEnd();
205 return [line(headers), line(widths.map((w) => "-".repeat(w))), ...rows.map(line)].join("\n");
206}
207
208/** Runs `task` over `items`, at most `limit` at once, in order of start. */
209export async function pool(items, limit, task) {
210 const results = new Array(items.length);
211 let next = 0;
212 const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
213 while (next < items.length) {
214 const index = next++;
215 results[index] = await task(items[index], index);
216 }
217 });
218 await Promise.all(workers);
219 return results;
220}