Skip to content

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

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