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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/220-a-stale-or-duplicated-bundle-is-a-red-su → main 1 commit fork
3 files
changed +164
−12
README.md +10 −2
| @@ -1243,6 +1243,9 @@ never checked itself. | ||
| 1243 | 1243 | stops the server before it listens**, naming the directory it could not write, |
| 1244 | 1244 | and so does a game that resolved to no bundle or to two: a failed deploy whose |
| 1245 | 1245 | 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. | |
| 1246 | 1249 | - **Every bundle has a ceiling, and the build prints what it built.** The |
| 1247 | 1250 | numbers are not written down here, because a number written down goes stale |
| 1248 | 1251 | the next time anyone lands anything: `bun run scripts/build.ts` prints a row |
| @@ -1264,8 +1267,13 @@ never checked itself. | ||
| 1264 | 1267 | number does not move between a laptop and the forge. |
| 1265 | 1268 | `scripts/build.test.ts` builds the sources and weighs both what the bundler |
| 1266 | 1269 | 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, | |
| 1269 | 1277 | what it measured, what it may not pass and the overshoot in bytes and |
| 1270 | 1278 | percent; the table says the same thing more quietly, as a headroom that has |
| 1271 | 1279 | gone negative. **Raising a ceiling is allowed**: edit the number on that |
scripts/build.test.ts +104 −10
| @@ -7,6 +7,7 @@ import { | ||
| 7 | 7 | assetOf, |
| 8 | 8 | bundle, |
| 9 | 9 | collect, |
| 10 | differences, | |
| 10 | 11 | ENTRIES, |
| 11 | 12 | isNamed, |
| 12 | 13 | GZIP_LEVEL, |
| @@ -24,6 +25,9 @@ import { | ||
| 24 | 25 | } from "./build.ts"; |
| 25 | 26 | import { GAMES } from "../src/games.ts"; |
| 26 | 27 | |
| 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 | ||
| 27 | 31 | /** Room enough that the fixture's two-line bundles are never the thing failing. */ |
| 28 | 32 | const ROOMY = { raw: 100_000, gzip: 100_000 }; |
| 29 | 33 | |
| @@ -145,21 +149,111 @@ test("a bundle that fits says nothing, and one that does not names the overshoot | ||
| 145 | 149 | expect(both).toContain("gzip 501 bytes over"); |
| 146 | 150 | }); |
| 147 | 151 | |
| 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 () => { | |
| 149 | 153 | // Nothing is committed any more, so this is what makes a deploy reproducible: |
| 150 | 154 | // every process that boots this tree serves files under the same names, and a |
| 151 | 155 | // 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 | } | |
| 161 | 182 | }, 60_000); |
| 162 | 183 | |
| 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 | ||
| 163 | 257 | test("a fresh build of every entry is under its ceiling", async () => { |
| 164 | 258 | // The sources as they are now: a fat import fails here on the first build, |
| 165 | 259 | // before anyone has deployed the bundle it produced. |
scripts/build.ts +50
| @@ -225,6 +225,56 @@ export async function write( | ||
| 225 | 225 | return built; |
| 226 | 226 | } |
| 227 | 227 | |
| 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 | ||
| 228 | 278 | /** |
| 229 | 279 | * One row of the build's table: what was built, what it weighs on both rulers, |
| 230 | 280 | * what it may not pass and what is left under that — negative when it passed it, |
new above
offline