Skip to content

Commit

A change to a crate's tests deploys nothing: tests/, benches/, examples/ and #[cfg(test)]-only sources are not what a unit is built from

0cdd221 changed only crates/actions/tests/repository_workflows.rs among Rust files, and the plan sent actions, security and the runner. Each unit now knows the Rust crates it is built from (crateDirs: its own crate, the workspace crates it depends on, and its image's crates), and touches and touchesImage pass over their tests/, benches/ and examples/ folders. A source file under a crate's src/ is passed over too when it is compiled only for tests: every `mod` declaration of it found (in its parent, lib.rs or main.rs at the top, or a #[path] in a file beside it) is under #[cfg(test)], or is in a file that is itself only for tests. The plan reads them at the commit being deployed (git show, git ls-tree), each file once, stopping at the first declaration that is built. A file nothing is found to declare counts as built, so it deploys. Replaying 0cdd221 against its parent now deploys nothing.

syntaqxcommitted Parentb9c4a50Browse files
4 files+216−40/4 viewed
+5−0
158158 1. Plans: for each unit, the live commit; `git diff` from it to `HEAD`;
159159 whether the changed files touch the unit (its folder, the crates and
160160 packages it is built from, its inputs, lockfile changes that reach it).
161+ A Rust crate's `tests/`, `benches/` and `examples/` are not what it is
162+ built from, and neither is a source file compiled only for tests: one
163+ whose every `mod` declaration is under `#[cfg(test)]` (with or without
164+ `#[path]`), or inside a module that is. A change to a crate's tests
165+ alone deploys nothing (`scripts/deploy/stack.mjs`, `testOnlySource`).
161166 2. Refuses if uncommitted changes touch what would deploy (`--allow-dirty`).
162167 3. Applies every pending migration (`wrangler d1 migrations apply --remote`),
163168 in parallel. Any failure stops the deploy before code.
+91−1
3939 versionFrom,
4040 } from "./cloudflare.mjs";
4141 import { changedNames, parseCargoLock, parseNpmLock, reaches } from "./lockfiles.mjs";
42−import { decide, planJson, pool } from "./plan.mjs";
42+import { decide, git, planJson, pool } from "./plan.mjs";
4343 import {
4444 ROOT,
4545 buildGroups,
5353 problems,
5454 resolveStack,
5555 resolvedStack,
56+ testOnlySource,
5657 touches,
5758 touchesBase,
5859 touchesImage,
328329 assert.ok(!ids(stack.units, ["packages/contracts/src/index.ts"]).includes("events"));
329330 });
330331
332+test("a crate's tests, benches and examples deploy nothing", () => {
333+ // 0cdd221 changed only this file, and the plan sent three Rust Workers.
334+ assert.deepEqual(ids(stack.units, ["crates/actions/tests/repository_workflows.rs"]), []);
335+ assert.ok(!touchesImage(unit("runner"), ["crates/actions/tests/repository_workflows.rs"]));
336+ assert.deepEqual(ids(stack.units, ["crates/kit/benches/wire.rs", "crates/scan/examples/scan.rs", "services/repos/tests/git.rs"]), []);
337+ // Their sources still do, and a folder merely named like one.
338+ assert.deepEqual(ids(stack.units, ["crates/actions/src/expr.rs"]), ["actions", "security", "runner"]);
339+ assert.ok(touchesImage(unit("runner"), ["crates/actions/src/expr.rs"]));
340+ assert.deepEqual(ids(stack.units, ["services/repos/src/tests/helpers.rs"]), ["repos"]);
341+ assert.deepEqual(ids(stack.units, ["crates/kit/testsuite/a.rs"]), ids(stack.units, ["crates/kit/src/lib.rs"]));
342+ // A unit that is not a crate keeps its whole folder.
343+ assert.deepEqual(unit("web").crateDirs, []);
344+ assert.deepEqual(unit("events").crateDirs, ["crates/contracts", "crates/kit", "services/events"]);
345+ assert.ok(unit("runner").crateDirs.includes("crates/runner"));
346+});
347+
348+/** Rust sources by path, as `testOnlySource` reads them. */
349+const sources = (files) => ({
350+ read: (path) => files[path] ?? "",
351+ list: (dir) => Object.keys(files).filter((path) => path.slice(0, path.lastIndexOf("/")) === dir),
352+});
353+
354+test("a Rust file compiled only for tests is found from what declares it", () => {
355+ const crate = ["crates/x"];
356+ const tree = sources({
357+ "crates/x/src/lib.rs": "pub mod api;\nmod store;\n#[cfg(test)]\nmod tests;\n#[cfg(test)] pub(crate) mod fixtures;\n#[cfg(any(test, feature = \"x\"))]\nmod both;\n",
358+ "crates/x/src/api.rs": "mod wire;\n#[cfg(test)]\n#[path = \"api_tests.rs\"]\nmod tests;\n",
359+ "crates/x/src/api/wire.rs": "",
360+ "crates/x/src/api_tests.rs": "use super::*;",
361+ "crates/x/src/store/mod.rs": "#[cfg(test)]\nmod memory;\n",
362+ "crates/x/src/store/memory.rs": "mod deep;",
363+ "crates/x/src/store/memory/deep.rs": "",
364+ "crates/x/src/tests.rs": "",
365+ "crates/x/src/fixtures/mod.rs": "",
366+ "crates/x/src/both.rs": "",
367+ "crates/x/src/bin/tool.rs": "",
368+ });
369+ const only = (file) => testOnlySource(file, crate, tree);
370+ for (const file of ["crates/x/src/tests.rs", "crates/x/src/fixtures/mod.rs", "crates/x/src/api_tests.rs", "crates/x/src/store/memory.rs", "crates/x/src/store/memory/deep.rs"]) {
371+ assert.ok(only(file), file);
372+ }
373+ // Built: roots, ordinary modules, a cfg that is not only test, a file
374+ // nothing declares (new, or deleted), and files outside src or the crate.
375+ for (const file of [
376+ "crates/x/src/lib.rs",
377+ "crates/x/src/api.rs",
378+ "crates/x/src/api/wire.rs",
379+ "crates/x/src/store/mod.rs",
380+ "crates/x/src/both.rs",
381+ "crates/x/src/bin/tool.rs",
382+ "crates/x/src/new.rs",
383+ "crates/x/build.rs",
384+ "crates/y/src/tests.rs",
385+ "crates/x/src/notes.md",
386+ ]) {
387+ assert.ok(!only(file), file);
388+ }
389+});
390+
391+test("this repository's test-only sources are read from git", () => {
392+ const head = git.head();
393+ const atHead = { read: (path) => git.show(head, path), list: (dir) => git.list(head, dir) };
394+ const only = (u, file) => testOnlySource(file, unit(u).crateDirs, atHead);
395+ assert.ok(only("api", "apps/api/src/responses.rs"));
396+ assert.ok(only("billing", "services/billing/src/catalogue_tests.rs"));
397+ assert.ok(only("billing", "services/billing/src/gateway_tests.rs"));
398+ assert.ok(!only("billing", "services/billing/src/catalogue.rs"));
399+ assert.ok(!only("api", "apps/api/src/toolkit.rs"));
400+});
401+
402+test("decide: a change only to tests deploys nothing", () => {
403+ const units = ["actions", "billing", "runner"].map(unit);
404+ const live = { actions: { sha: OLD }, billing: { sha: OLD }, runner: { sha: OLD } };
405+ const files = {
406+ [`${HEAD}:services/billing/src/gateway.rs`]: "#[cfg(test)]\n#[path = \"gateway_tests.rs\"]\nmod tests;\n",
407+ [`${HEAD}:services/billing/src/lib.rs`]: "mod gateway;\n",
408+ };
409+ const gitApi = {
410+ ...fakeGit(["crates/actions/tests/repository_workflows.rs", "services/billing/src/gateway_tests.rs"]),
411+ show: (sha, path) => files[`${sha}:${path}`] ?? "",
412+ list: (sha, dir) => Object.keys(files).map((k) => k.slice(sha.length + 1)).filter((p) => p.startsWith(`${dir}/`)).concat(dir === "services/billing/src" ? ["services/billing/src/gateway_tests.rs"] : []),
413+ };
414+ const decisions = decide(units, { live, head: HEAD, gitApi });
415+ assert.deepEqual(decisions.map((d) => [d.unit.id, d.deploy, d.image]), [["actions", false, false], ["billing", false, false], ["runner", false, false]]);
416+ // With a source that ships beside them, only that is listed.
417+ const mixed = decide([unit("billing")], { live, head: HEAD, gitApi: { ...gitApi, changed: () => ["services/billing/src/gateway_tests.rs", "services/billing/src/gateway.rs"] } });
418+ assert.deepEqual([mixed[0].deploy, mixed[0].files], [true, ["services/billing/src/gateway.rs"]]);
419+});
420+
331421 test("own folders, declared inputs and root files", () => {
332422 assert.deepEqual(ids(stack.units, ["services/pages/src/index.ts"]), ["pages"]);
333423 assert.deepEqual(ids(stack.units, ["apps/web/app/lib/roadmap.ts"]), ["og", "web"]);
+31−2
55 import { execFileSync } from "node:child_process";
66
77 import { changedNames, lockRoots, parseCargoLock, parseNpmLock, reaches } from "./lockfiles.mjs";
8−import { ROOT, byStage, buildGroups, codeStages, touches, touchesImage } from "./stack.mjs";
8+import { ROOT, byStage, buildGroups, codeStages, testOnlySource, touches, touchesImage } from "./stack.mjs";
99
1010 /** Git, read-only. */
1111 export const git = {
2121 return false;
2222 }
2323 },
24− /** Files changed between two commits. */
2524 /** A file's text at a commit, or "" if it is not there. */
2625 show: (sha, path) => {
2726 try {
3029 return "";
3130 }
3231 },
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. */
3341 changed: (from, to) => run(["diff", "--name-only", "--no-renames", from, to]).split("\n").filter(Boolean),
3442 /** Whether `older` is in `newer`'s history (and not the same commit). */
3543 isAncestor: (older, newer) => {
8290 }
8391 return locks.get(key);
8492 };
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+ };
85113 const relevant = (unit, sha, files) =>
86114 files.filter((file) => {
115+ if (onlyForTests(unit, file)) return false;
87116 if (file !== "Cargo.lock" && file !== "package-lock.json") return true;
88117 const { after, names } = lockChange(sha, file);
89118 const roots = lockRoots(unit)[file === "Cargo.lock" ? "cargo" : "npm"];
+89−1
173173 unit.crate = crate;
174174 unit.pkg = pkg;
175175 unit.dependsOn = [...dirs].sort();
176+ // The Rust crates it is built from, whose tests, benches and examples
177+ // are not.
178+ unit.crateDirs = crate ? [unit.path, ...closure(cargo, crate).map((name) => cargo.get(name).dir)].sort() : [];
176179 unit.inputs = [...new Set([...(unit.inputs ?? []), ...globalInputs(unit.kind)])].sort();
177180 if (unit.image) {
178181 const name = unit.image.crate;
189192 };
190193 for (const dir of unit.image.dirs) if (dir !== unit.path) unit.dependsOn.push(dir);
191194 unit.dependsOn = [...new Set(unit.dependsOn)].sort();
195+ unit.crateDirs = [...new Set([...unit.crateDirs, ...unit.image.dirs])].sort();
192196 unit.inputs = [...new Set([...unit.inputs, "Cargo.toml", "Cargo.lock", "scripts/build-runner.mjs"])].sort();
193197 }
194198 }
202206
203207 const under = (file, dir) => file === dir || file.startsWith(`${dir}/`);
204208
209+/** A Rust crate's folders that its library and binaries are never built from. */
210+export const CRATE_TEST_DIRS = ["tests", "benches", "examples"];
211+
212+/**
213+ * Whether `file` is in the tests, benches or examples of one of
214+ * `crateDirs`: Cargo builds those only for `cargo test`, `cargo bench` and
215+ * `--example`, never into what deploys.
216+ */
217+export function inCrateTests(file, crateDirs = []) {
218+ return crateDirs.some((dir) => CRATE_TEST_DIRS.some((sub) => under(file, `${dir}/${sub}`)));
219+}
220+
221+const dirOf = (path) => path.slice(0, Math.max(0, path.lastIndexOf("/")));
222+const baseOf = (path) => path.slice(path.lastIndexOf("/") + 1);
223+
224+/**
225+ * The `mod name;` declarations in Rust source `text` that `match` (by name,
226+ * or by a `#[path]`), each with whether it is under `#[cfg(test)]`.
227+ * Only outline modules (`mod x;`); the attributes are those directly above.
228+ */
229+function declarations(text, match) {
230+ const found = [];
231+ const pattern = /((?:#\[[^\]]*\]\s*)*)(?:pub(?:\([^)]*\))?\s+)?mod\s+(r#)?(\w+)\s*;/g;
232+ for (const [, attrs, , name] of text.matchAll(pattern)) {
233+ const path = /#\[\s*path\s*=\s*"([^"]+)"\s*\]/.exec(attrs)?.[1] ?? null;
234+ if (!match(name, path)) continue;
235+ found.push(/#\[\s*cfg\s*\(\s*test\s*\)\s*\]/.test(attrs));
236+ }
237+ return found;
238+}
239+
240+/**
241+ * Whether Rust source `file`, in the `src/` of one of `crateDirs`, is
242+ * compiled only for tests: every module declaration of it found is under
243+ * `#[cfg(test)]`, or is in a file that is itself only for tests. A file
244+ * nothing is found to declare (a new crate root, a deleted file, a module
245+ * declared some way this does not read) counts as built, so a change to it
246+ * deploys.
247+ *
248+ * read(path): a file's text at the commit being deployed, "" if absent
249+ * list(dir): the files directly in a folder at that commit
250+ */
251+export function testOnlySource(file, crateDirs, { read, list = () => [] }, depth = 0) {
252+ if (!file.endsWith(".rs") || depth > 16) return false;
253+ const crateDir = crateDirs.find((dir) => file.startsWith(`${dir}/src/`));
254+ if (!crateDir) return false;
255+ const src = `${crateDir}/src`;
256+ const dir = dirOf(file);
257+ const base = baseOf(file).slice(0, -".rs".length);
258+ // Crate roots and binaries are not declared by anything.
259+ if (dir === src && ["lib", "main"].includes(base)) return false;
260+ if (dir === `${src}/bin` || (base === "main" && dirOf(dir) === `${src}/bin`)) return false;
261+ // Where `mod name;` for this file would be: `a/name.rs` and `a/name/mod.rs`
262+ // are declared by `a.rs` or `a/mod.rs` (by `lib.rs` or `main.rs` in src).
263+ const name = base === "mod" ? baseOf(dir) : base;
264+ const parentDir = base === "mod" ? dirOf(dir) : dir;
265+ const parents =
266+ parentDir === src ? [`${src}/lib.rs`, `${src}/main.rs`] : [`${parentDir}/mod.rs`, `${dirOf(parentDir)}/${baseOf(parentDir)}.rs`];
267+ // One declaration that is built is enough to stop: most changed files
268+ // are ordinary modules, settled by reading their parent.
269+ let declared = false;
270+ const testOnly = (declarer, isTest) => {
271+ declared = true;
272+ return isTest || testOnlySource(declarer, crateDirs, { read, list }, depth + 1);
273+ };
274+ for (const parent of parents) {
275+ for (const isTest of declarations(read(parent), (found, path) => found === name && path === null)) {
276+ if (!testOnly(parent, isTest)) return false;
277+ }
278+ }
279+ // `#[path = "x.rs"] mod y;` in a file beside it names it directly.
280+ const fileName = baseOf(file);
281+ for (const sibling of list(dir).filter((path) => path.endsWith(".rs") && path !== file)) {
282+ for (const isTest of declarations(read(sibling), (_, path) => path === fileName || path === `./${fileName}`)) {
283+ if (!testOnly(sibling, isTest)) return false;
284+ }
285+ }
286+ return declared;
287+}
288+
205289 /**
206290 * Why a set of changed files (repository-relative, `/`-separated) touches
207291 * a unit: the first file that does, and through what. Null if none does.
292+ * Its crates' tests, benches and examples do not.
208293 */
209294 export function touches(unit, files) {
295+ files = files.filter((file) => !inCrateTests(file, unit.crateDirs));
210296 for (const file of files) {
211297 if (under(file, unit.path)) return { file, via: "its own folder" };
212298 }
221307 /** Whether changed files touch a unit's Containers image. */
222308 export function touchesImage(unit, files) {
223309 if (!unit.image) return false;
224− return files.some((file) => unit.image.files.includes(file) || unit.image.dirs.some((dir) => under(file, dir)));
310+ return files.some(
311+ (file) => unit.image.files.includes(file) || (unit.image.dirs.some((dir) => under(file, dir)) && !inCrateTests(file, unit.image.dirs)),
312+ );
225313 }
226314
227315 /** Whether changed files touch the folder a unit's base image is built from. */