Public: anyone can read this; the crew can change it.

forked from trevato/cs

A stale or duplicated bundle is a red suite, not a dead deploy: the committed static/ is proved to be what the sources build #282

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/cs-engine:torque/220-a-stale-or-duplicated-bundle-is-a-red-su → main 1 commit fork

  • Merged. torque/220-a-stale-or-duplicated-bundle-is-a-red-su was merged into main as d2c980a.

Review

Sign in to review this change.
3 files changed +164 −12
README.md +10 −2
@@ -1243,6 +1243,9 @@ never checked itself.
12431243 stops the server before it listens**, naming the directory it could not write,
12441244 and so does a game that resolved to no bundle or to two: a failed deploy whose
12451245 first line says so, rather than a perfect page around a `<script>` that 404s.
1246 `scripts/build.test.ts` holds the rule rather than trusting it: it asks `git
1247 ls-files` for anything named by its own hash or filed under `static/`, and a
1248 committed artifact fails the check with the `git rm --cached` that undoes it.
12461249 - **Every bundle has a ceiling, and the build prints what it built.** The
12471250 numbers are not written down here, because a number written down goes stale
12481251 the next time anyone lands anything: `bun run scripts/build.ts` prints a row
@@ -1264,8 +1267,13 @@ never checked itself.
12641267 number does not move between a laptop and the forge.
12651268 `scripts/build.test.ts` builds the sources and weighs both what the bundler
12661269 returned and what landed on disk against both ceilings — and the same test
1267 proves two builds of one tree produce the same bytes under the same names,
1268 which is what makes a year-long cache safe. The failure names the bundle,
1270 proves that a build made in *another process*, the way a deploy makes one, is
1271 the same names and the same bytes: `differences(built, dir)` reads a directory
1272 and says every way it is not what these sources build — a file missing, a
1273 file whose bytes are not the built bytes, a second file under a name that
1274 belongs to one — and the test asserts it has nothing to say. That is what
1275 makes a year-long cache safe, and it is the guard that three outages went
1276 without. The failure names the bundle,
12691277 what it measured, what it may not pass and the overshoot in bytes and
12701278 percent; the table says the same thing more quietly, as a headroom that has
12711279 gone negative. **Raising a ceiling is allowed**: edit the number on that
scripts/build.test.ts +104 −10
@@ -7,6 +7,7 @@ import {
77 assetOf,
88 bundle,
99 collect,
10 differences,
1011 ENTRIES,
1112 isNamed,
1213 GZIP_LEVEL,
@@ -24,6 +25,9 @@ import {
2425 } from "./build.ts";
2526 import { GAMES } from "../src/games.ts";
2627
28 /** What a built file looks like whatever it is: a plain name, its content hash, an extension. */
29 const STAMPED = /^[a-z][a-z0-9-]*\.[0-9a-f]{10}\.[a-z0-9]+$/;
30
2731 /** Room enough that the fixture's two-line bundles are never the thing failing. */
2832 const ROOMY = { raw: 100_000, gzip: 100_000 };
2933
@@ -145,21 +149,111 @@ test("a bundle that fits says nothing, and one that does not names the overshoot
145149 expect(both).toContain("gzip 501 bytes over");
146150 });
147151
148 test("two builds of one tree produce the same bytes and the same names", async () => {
152 test("a build in another process is the same names and the same bytes as this one", async () => {
149153 // Nothing is committed any more, so this is what makes a deploy reproducible:
150154 // every process that boots this tree serves files under the same names, and a
151155 // browser's year-long cache of `app.<hash>.js` stays correct across deploys
152 // that changed nothing. Bun's output is byte-stable here — the whole `code`
153 // is compared, not just the hash of it — and if that ever stops being true
154 // this fails with the two bundles in hand rather than in a preview.
155 const first = await bundle();
156 const second = await bundle();
157 expect(second.map((b) => b.file)).toEqual(first.map((b) => b.file));
158 expect(second.map((b) => b.code)).toEqual(first.map((b) => b.code));
159 // The assets too, which are copies and must hash to what their source holds.
160 expect(collect().map((b) => b.file)).toEqual(collect().map((b) => b.file));
156 // that changed nothing. Two builds in one process would share whatever Bun
157 // caches between them, so the second one is a child process with its own
158 // start-up — the same thing a deploy does — and the whole `code` is compared,
159 // not just the hash of it. If Bun's output ever stops being byte-stable this
160 // fails here, with the file and the two sizes in hand, rather than in a
161 // preview that serves a name nobody else builds.
162 const dir = mkdtempSync(join(tmpdir(), "elsewhere-"));
163 try {
164 const run = Bun.spawnSync(["bun", "run", join(import.meta.dir, "build.ts"), dir], {
165 stdout: "pipe",
166 stderr: "pipe",
167 });
168 expect(run.stderr.toString().trim()).toBe("");
169 expect(run.exitCode).toBe(0);
170
171 const built = [...(await bundle()), ...collect()];
172 // Every difference this build has from that one, as sentences: none.
173 expect(differences(built, dir)).toEqual([]);
174 // And that directory holds one file per game and per asset and nothing
175 // else — a second shooter bundle beside the first is the outage this is
176 // about, and it would be both an extra name here and a sentence above.
177 expect(readdirSync(dir).sort()).toEqual(built.map((b) => b.file).sort());
178 expect(built.map((b) => b.name)).toEqual([...ENTRIES.map((e) => e.name), ...ASSETS.map((a) => a.name)]);
179 } finally {
180 rmSync(dir, { recursive: true, force: true });
181 }
161182 }, 60_000);
162183
184 test("a directory that is not what the sources build says which file, and what to run", async () => {
185 const { dir, entries } = fixture();
186 try {
187 const built = await write(entries, dir, []);
188 const [app, sandbox] = built as [Built, Built];
189 // What a build just wrote is what the build builds: nothing to say. The
190 // fixture's own `.ts` sources sit in this directory too, and are not the
191 // build's business — neither is the database beside the bundles in
192 // FORGE_APP_DATA.
193 expect(differences(built, dir)).toEqual([]);
194 writeFileSync(join(dir, "keep.txt"), "not a bundle");
195 expect(differences(built, dir)).toEqual([]);
196
197 // The loud half of the outage: two bundles of one game, and nothing says
198 // which is the tree.
199 writeFileSync(join(dir, "app.0123456789.js"), app.code);
200 const two = differences(built, dir);
201 expect(two.length).toBe(1);
202 expect(two[0]).toContain("1 stale file");
203 expect(two[0]).toContain("app.0123456789.js");
204 expect(two[0]).toContain(`beside ${app.file}`);
205 expect(two[0]).toContain("bun run scripts/build.ts");
206
207 // The quiet half, which used to pass without a word: one valid-looking
208 // file, and it is not this tree's.
209 rmSync(join(dir, app.file));
210 const stale = differences(built, dir);
211 expect(stale.length).toBe(1);
212 expect(stale[0]).toContain(`has no ${app.file}`);
213 expect(stale[0]).toContain("app.0123456789.js");
214 expect(stale[0]).toContain("bun run scripts/build.ts");
215
216 // Nothing at all under that name says so in its own words.
217 rmSync(join(dir, "app.0123456789.js"));
218 expect(differences(built, dir)[0]).toContain("nothing there is named app.<hash>.js");
219 expect(differences(built, dir).length).toBe(1);
220
221 // And a file edited in place keeps its name and is caught on its bytes,
222 // which is the one thing a content hash cannot notice by itself.
223 writeFileSync(join(dir, sandbox.file), `${sandbox.code}// and more\n`);
224 const edited = differences([sandbox], dir);
225 expect(edited.length).toBe(1);
226 expect(edited[0]).toContain(`${join(dir, sandbox.file)} is not what this tree builds`);
227 expect(edited[0]).toContain(`${sizes(`${sandbox.code}// and more\n`).raw} bytes there`);
228 expect(edited[0]).toContain(`${sizes(sandbox.code).raw} bytes built`);
229 } finally {
230 rmSync(dir, { recursive: true, force: true });
231 }
232 });
233
234 test("no generated file is in git: the repository tracks nothing named by its own hash", () => {
235 const root = join(import.meta.dir, "..");
236 // A source export with no `.git` beside it cannot be asked; a checkout can,
237 // and that is where this class of outage was born, so it is asked there.
238 if (!existsSync(join(root, ".git"))) return;
239 const ls = Bun.spawnSync(["git", "ls-files"], { cwd: root, stdout: "pipe", stderr: "pipe" });
240 expect(ls.exitCode).toBe(0);
241 const said = ls.stdout
242 .toString()
243 .split("\n")
244 .filter((path) => path !== "")
245 .filter((path) => path.startsWith("static/") || STAMPED.test(path.split("/").pop() as string))
246 .map(
247 (path) =>
248 `${path} is a generated file and it is committed: run \`git rm --cached ${path}\` — ` +
249 `the bundles are built at boot into FORGE_APP_DATA/static, and an artifact in no branch cannot collide in a merge`,
250 );
251 expect(said).toEqual([]);
252 // The reason nobody adds one by accident: a build into the repository's own
253 // static/ is ignored where it lands.
254 expect(readFileSync(join(root, ".gitignore"), "utf8").split("\n")).toContain("static/");
255 });
256
163257 test("a fresh build of every entry is under its ceiling", async () => {
164258 // The sources as they are now: a fat import fails here on the first build,
165259 // before anyone has deployed the bundle it produced.
scripts/build.ts +50
@@ -225,6 +225,56 @@ export async function write(
225225 return built;
226226 }
227227
228 /**
229 * Every way a directory is not what these sources build, as sentences a person
230 * can act on: a file this tree builds that is not there, a file filed under a
231 * built name whose bytes are not the built bytes, and a second file under a
232 * name that belongs to one — the shape of three outages, where two branches
233 * each rebuilt the shooter and git kept both.
234 *
235 * It ignores everything it was not asked about: the database beside the
236 * bundles in `FORGE_APP_DATA`, a note somebody left in `static/`. `write`
237 * sweeps by name and extension and nothing else, so this reads the same
238 * directory the same way.
239 *
240 * Empty is the only good answer, and it is what `build.test.ts` asserts of a
241 * build made in another process: the same names and the same bytes, which is
242 * what a year-long `immutable` cache of `app.<hash>.js` is a promise about.
243 */
244 export function differences(built: readonly Built[], dir: string): string[] {
245 const present = readdirSync(dir);
246 const said: string[] = [];
247 const rebuild = `run \`bun run scripts/build.ts ${dir}\` and compare again`;
248 for (const { name, ext, file, code } of built) {
249 const mine = present.filter((old) => isNamed(name, ext, old));
250 const stale = mine.filter((old) => old !== file);
251 if (!mine.includes(file)) {
252 const instead =
253 mine.length === 0 ? `nothing there is named ${name}.<hash>.${ext}` : `it holds ${mine.join(", ")}`;
254 said.push(
255 `${dir} has no ${file}, which is what this tree builds for ${JSON.stringify(name)}: ` +
256 `${instead} — ${rebuild}`,
257 );
258 } else {
259 const there = readFileSync(join(dir, file), "utf8");
260 if (there !== code)
261 said.push(
262 `${join(dir, file)} is not what this tree builds under that name: ` +
263 `${sizes(there).raw} bytes there, ${sizes(code).raw} bytes built — ${rebuild}`,
264 );
265 }
266 // Only beside the built file: when that one is missing the sentence above
267 // already read out what is there instead, and one name deserves one
268 // sentence.
269 if (mine.includes(file) && stale.length > 0)
270 said.push(
271 `${dir} holds ${stale.length} stale file${stale.length === 1 ? "" : "s"} for ${JSON.stringify(name)} ` +
272 `beside ${file}: ${stale.join(", ")} — whoever picks between them picks at random, so ${rebuild}`,
273 );
274 }
275 return said;
276 }
277
228278 /**
229279 * One row of the build's table: what was built, what it weighs on both rulers,
230280 * what it may not pass and what is left under that — negative when it passed it,

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs