Repository navigation
Conversation
Replaces pnpm and node as installer, script runner, test runner and build runtime. The bar was byte-identical output, never "the build succeeds": /a/ and /i/ are content-addressed, so one byte of drift re-mints URLs and orphans every committed dictionary. All 1484 files match the node baseline, with bun installing and node building, and with bun doing both. 291 contract tests, 59 cal, 13 lens-reader, lint, typecheck and the 136-route oracle all clean. The pin is the binary SHA-256, not the version string. bun 1.4 is unreleased and on npm in no form, so it ships only as a GitHub asset under the tag `canary`, and that tag rolls: the asset carried updated_at of this morning, the release is titled after a different commit than the binary reports, and no immutable per-canary tag exists. `1.4.0-canary.1` is therefore a label that stays fixed while the binary moves daily, which is the worst shape for a content-addressed artifact graph. config/bun-canary.json declares the digest and .github/actions/setup-bun fails by name when it moves. That action also probes the capability the version is FOR. bun 1.3.14 accepts zstdCompressSync's `dictionary` and silently ignores it (none=65 good=65 wrong=65, against node's 65/18/64), which ships no-op dcz deltas. build.mjs already throws on that, but 40 seconds in. Three things refused to swap, all measured: - routes:check stays on node. Under bun it hangs with no output and no error, 5 workerd children idle, killed at 13 minutes; node sweeps 136 routes and exits 0. Narrowed to listen(), where workerd is spawned. wrangler, miniflare and workerd are node-pinned and this is that pin surfacing. - `pnpm exec X` became bare `X`, never `bunx X`. In an empty directory `bun x wrangler --version` downloaded and ran 4.123.0 from the registry, which is the npx hole gotcha 29 closed wearing another name. The eight scripts that spawn wrangler outside a package script use `bun x --no-install`. - contract-tests.mjs is contract-tests.test.mjs. bun test requires `.test` in the filename even when handed an explicit path. Two policies lost fidelity and both are silent: the nanoid override was a version SELECTOR that expired on its own and is now an unconditional pin, and minimumReleaseAgeExcludes now exempts by name rather than name@version, so every entry is permanent until pruned. minimumReleaseAge also changed units, minutes to seconds; 1440 became 86400. Workers Builds is deliberately untouched. infra.json records the dashboard's deploy commands and check-infra.mjs compares by exact string, so editing them first fails on drift it invented itself. The image also cannot get bun 1.4: BUN_VERSION resolves released versions and there is no such release. Full record in docs/BUN-MIGRATION.md.
www/_worker.js/ is src/worker/, beside cal/ and serendipity/, for the reason the layout table already gave for those two: it is a program with its own tests rather than something a browser fetches. It never was fetchable either, since .assetsignore has always listed _worker.js. Every served byte is unchanged. 1402 served files byte-identical, including every /a/ and /i/ hashed URL, all 48 pages, all 144 dcz deltas and the family dictionary. Nothing re-minted, no dictionary orphaned. The five files that differ are the ones that reference the moved path, four of them the www/scripts/ photo tooling that .assetsignore also excludes from upload. Worth stating because the assumption going in was the opposite: a relocation does not re-mint content hashes, only a change of CONTENT does. The staged layout had to mirror the source layout, and that is the real constraint rather than a preference. Keeping the Worker staged at .build/www/_worker.js while the source moved is impossible: cal/src/templates.js and serendipity.js import it across the project boundary and are bundled from .build/, so one relative specifier has to resolve in both the source and the staged tree, which only works when the two have the same shape. So it stages to .build/src/worker/ and wrangler main points there. photos.js reaching ../images/hashes.json is now ../../www/images/hashes.json, which resolves from both trees exactly like the ../../cal/src/* imports already did. Three walks had been finding the Worker by accident, through .build/www plus a _worker.js/ prefix filter. The shell-ref rewrite would have left /a/ specifiers unhashed; the witness checks failed loudly with ENOENT; the client-edge CSS mirror failed SILENTLY and shipped /bot and /lens two pages short, because its tripwire was `mirrored < 25` against a real count of 33. Eight pages of slack and the regression only cost two. Caught by diffing served bytes, which is the argument for that bar over "the build succeeds". Floor is 32 now. The last failure was a contract test whose import pin is an escaped regex, so the literal path never appears in it and no tree-wide search could see it. Gotcha 29's blind spot from the other direction.
…e they were
The 15 client islands and 5 stylesheets move out of the root of www/ into
src/client/ and src/styles/, and stage back to the root of the served tree so
their public URLs are still /nav.js and /luna.css.
Served surface byte-identical: all 1402 files, every /a/ content hash included.
Second confirmation that source layout and URL layout are separate questions.
Two things surfaced. The staging loops read `www/${file}`, so no literal
"www/nav.js" existed for a tree-wide search to find and the build died at the
first shell with ENOENT; that is the third time in this migration a path built
from parts has hidden from a search for the assembled form. And the served URL
root is a COMPOSITE now, so tsconfig.browser.json's `paths` mapping of /* at
../www/* stopped resolving `import "/hoist.js"` inside nav.js. It names all
three source directories now.
291 contract tests, typecheck, lint and the 136-route oracle all clean.
All 67 Worker modules are .ts. Deployed bundle unchanged at 666.62 KiB / 227.77 KiB gzip, because types erase and nothing else did. Specifiers say .ts, which is forced rather than stylistic. Measured across the three runtimes that have to agree: bun and wrangler/esbuild resolve both ./x.js and ./x.ts to x.ts, and node 26 resolves ONLY ./x.ts. node has to work because contract-tests imports these modules under plain node, which is what gotcha 16 exists to protect. allowImportingTsExtensions is what lets tsc agree. 381 type errors, and none of them are new defects. The code is what shipped as JavaScript. A .js file lets TypeScript treat EVERY parameter as optional and infer object shapes loosely, so TS2554 (arity, 92) and TS2339 (unknown property, 269) were structurally invisible: 361 of the 381. 36 of the 67 modules were already clean and are now fully checked. The other 31 carry // @ts-nocheck and are declared in config/ts-migration.json with per-file counts, capped by a contract test so the list may only shrink. Verified by control that an undeclared @ts-nocheck fails by name. One error was worth fixing rather than filing: mcpServer's result(id, payload, cache) had three required parameters and three call sites in serendipity.js passed two. Harmless, since ...cache spreads undefined as a no-op, but the signature had been lying about its own contract for as long as it was JavaScript. It says cache? now. .oxlintrc.json disabled six type-aware rules for **/*.js with no comment, and that entry turned out to be why none of them ever fired on the Worker. Four switched on and found 9 things. ledger.ts built `new Set()`, inferring Set<any>, so require-array-sort-compare could not tell a safe default string sort from an unsafe one; it says Set<string> now. tui.ts spreading a string is deliberate, since it renders fixed 80-column frames and counts what occupies a column rather than UTF-16 units, and has its own entry saying so. The rest are modules in the migration inventory, where those rules report the absence of types rather than a defect, pinned to that inventory so they expire with it. The /*min*/ CSS pass over the Worker filtered on .js and, after the rename, matched nothing. It had NO floor, so it printed "0 literals across 0 modules" and the build carried on shipping every Worker-rendered page with unminified CSS. That read on the wire as a 3.92 KiB bundle regression that looked like a TypeScript tax and was not. Floor is 7 now, the true count. Three walks in this migration located Worker modules by extension, and the two without a counted tripwire are the two that failed silently. 292 contract tests, 59 cal, 13 lens-reader, typecheck, lint and the 136-route oracle all clean. Served surface byte-identical.
www/scripts/ is tools/photos/. It ships nothing, so .assetsignore no longer needs its `scripts` entry and the build stages 28 fewer files: the served tree goes 1402 to 1374. FIRST move in this restructure that is not byte-neutral, and the reason is worth more than the move. www/icons.svg is generated and its banner named the generator BY PATH. That sprite is content-hashed and served from /a/, so a path inside its bytes coupled the repository layout to a public URL: moving the generator changed the banner, changed the sprite, and re-minted /a/icons.9ff130b8.svg to /a/icons.8d4b5912.svg. The build's own invariant caught it by name. The cascade is fully accounted for. 46 pages reference that URL, so 46 pages changed, which changed the page-family dictionary, which changed all 144 dcz deltas. One comment, one asset, 200-odd files. The banner names the generator by NAME now, no directory, so it cannot recur. General rule worth keeping: never put a repository path inside a content-addressed artifact. It turns a file move into a public URL change and orphans every dictionary naming the old one. This branch will need a dict:roll once it deploys, because the committed a-dict entries for the old hash are stale. Also caught: every script there resolves its root two levels up, which survives because www/scripts and tools/photos sit at the same depth. gen-photo-semantics.mjs was the one exception, using dirname(dirname(here)) so its root was www/ rather than the repository, and it would have silently begun reading tools/images. It matches its siblings now. 292 contract tests, typecheck, lint, tools:check and the 136-route oracle all clean.
The scan before splitting www/, made permanent as a contract test. icons.svg re-minted a public URL because a repo path lived inside a content-addressed artifact, so the sensible move is finding every other instance rather than discovering them from a red build. Two findings, and the first was self-inflicted. TEN STALE PATHS in served bytes, most created by this branch. Byte-identity discipline says to revert served files so the bytes do not move, and the cost is prose left behind: pages went on naming www/_worker.js/index.js, www/_worker.js/counter.js, www/scripts/zenc/ and www/scripts/add-photos.sh long after each had moved. /garage/workers is a page ABOUT the Worker and it cited a directory that no longer existed. One predates the branch. The rule cuts against the discipline that produced it: preserving bytes preserves stale documentation too. A move is finished when the prose still describes where things are, not when the build matches. ONE LIVE COUPLING in a content-addressed asset. /a/lens-browser.<hash>.js carries the string `node scripts/lens-webmcp.mjs`, printed so a visitor can run the probe themselves. A real path in a real command, so it cannot go path-free the way the icons banner did. Consequence, known now rather than later: moving scripts/ to tools/ will re-mint that asset and orphan its three a-dict entries. A useful distinction fell out. A COMMENT in a client island reaches only its .src.js twin, since minification strips it; a STRING LITERAL reaches the hashed asset too. So the five stale comments cost page bytes alone, and every /a/ URL was verified unchanged after the fixes. The check itself had to lose three false positives first. Its first version reported `tools/` nine times and every one was MCP's tools/list and tools/call, the naive-scanner trap this repo has now caught four times on its own output; it also flagged a route (cal/coffee), build output that exists only under .build (www/ad/), and a comment whose whole job is recording that a directory moved. The principle that removed all three without an exception list: only a token naming a FILE is a citation that has to resolve. One allowlist entry remains, src/buttcrack/, a file in somebody else's repository cited as attribution. It reads the built tree, counts what it scanned so a matcher that stops matching cannot pass, and was verified by planting a citation to a nonexistent file. 293 contract tests, typecheck, lint, pages:check and the 136-route oracle clean. Bundle unchanged at 666.62 KiB / 227.77 KiB gzip.
The bar changed here deliberately. Everything before this was gated on a byte
diff; this is gated on interoperability, because the point of the layout is what
it makes possible next rather than what it preserves.
www/**/*.html -> src/pages/ a page is a document
www/{md,writing,*.md} -> src/content/ authored prose and registries
www/{a,p}-dict/ -> src/dict/ build input, never served
everything else -> public/ byte-for-byte assets
scripts/ -> tools/ merged with photos/ and oxlint/
36 documents, 803 assets. The served layout is unchanged, so every public URL is
where it was: 2650 internal references resolve, all 136 routes pass, the Worker
bundle is unmoved at 666.62 KiB, and 293 contract tests, typecheck, lint,
pages:check and tools:check are clean.
.build/www is .build/public, and that is load-bearing rather than cosmetic. A
relative specifier has to resolve in the source tree AND the staged tree, which
is only possible when the two share a name; src/worker/photos.ts importing
../../public/images/hashes.json is the case that forced it. Third time the same
rule has decided a move, after the Worker and the islands.
ONE REGEX, SIX BLIND SPOTS. A negative lookbehind kept the rewrite out of
.build/www and hid every reference reached through a prefix or interpolation:
bare `readdir("www")`, relative `../../www/`, template `${root}/www/`,
interpolated `www/${dir}`, separator-less `www${p}.html`, and extensionless
files. Each was found by a different failure, none by reading the code. The
separator-less one silently degraded 44 Markdown twins to 35 and moved most from
authored source to generated HTML, which nothing reports; the count in the build
log is what showed it.
THE EXPENSIVE LESSON, LEARNED TWICE. a-dict and p-dict were classified as assets
because the test wanted a trailing slash and a bare directory name did not
match. The build then found no dictionaries and emitted zero per-page deltas,
144 down to 48, with no error: it reads as staleness and it was a wrong path.
Then the same blanket rewrite ran over prose and ERASED HISTORY, rewriting rows
in CLAUDE.md and twelve lines of the migration doc that record what something
used to be called, so the record of the move destroyed the record of the move.
Restored by hand. Both are one mistake: a path rewrite is a semantic edit, not a
textual one, and it has to know whether a string is a live reference, a
historical one, or a name that merely looks like a path.
Dev keeps serving readable source. wrangler dev pointed at www/, which is gone,
so tools/compose-dev.mjs assembles .dev/public from the five source roots with
no transforms. Deliberately not build.mjs: dev needs composition, and
minification, hashing, precompression and dictionaries are deploy concerns.
Adopted 1 shell asset (lens-boot.a8fd9733.js) and 6 page snapshots, pruning 6 superseded ones at KEEP=3 shell / KEEP=2 page. Shell deltas 17 -> 18. The per-page tier shrinks 23880 -> 22903 bytes across the same 96 deltas, about 4%. Source is PRODUCTION, via --live, and that is the right base even though production is not serving this branch. A dictionary is the bytes a returning browser already HOLDS, and what every returning visitor holds today is production. The delta transforms that into what we ship next. Which means this roll expires the moment the branch actually deploys: the snapshots then describe a release nobody is serving any more, and the tier wants rolling again against the new production. Nothing here can do that yet, because Workers Builds still cannot run an unreleased bun. 293 contract tests and lint clean.
Verdict: no, on all three, and the JS half is the one worth knowing. CSS lightningcss 7746 br vs bun 11356 br +46.6% JS oxc-minify 60706 br vs bun 62133 br +2.4% HTML bespoke pipeline vs bun fails to build THE CSS PANIC IS FIXED, which changes the argument rather than the answer. An earlier canary crashed inspecting a Bun.build result for luna.css; this one returns cleanly, and returns 69806 bytes from a 69503-byte source because Bun emits sRGB, P3 and LAB fallbacks by default. Keeping lightningcss is a size decision now, not a crash workaround, which is more durable and easier to re-check. CUTTING AROUND THE CSS DOES NOT RESCUE THE JS HALF. That was the hypothesis and it does not survive: 13 of 15 islands come out larger, the total is 2.4% worse on the wire, and tooltip.js loses the `function start` marker build.mjs asserts. Only lens-reader (-2.7%) and lens-wire (-2.0%) improve. On render-blocking shell assets that ship q11-precompressed and carry dictionary deltas, 2.4% is the wrong direction. The HTML entrypoint was the one that would have bought a CAPABILITY rather than bytes, replacing SHELLS, STRING_ASSETS and the hashed-asset repointer. It fails for an architectural reason rather than a bug: every page references /luna.css, /nav.js and /quiz.js by ABSOLUTE URL so all 48 documents share ONE content-hashed copy each. A bundler cannot resolve those, and making them relative would give every page its own copy, which is what the /a/ tier exists to prevent. The shape of the whole question: Bun wins where it RUNS code and loses where it TRANSFORMS bytes. Install, scripts, tests, the build runtime and TypeScript are adopted and paying; the transform layer is specialised and a general bundler does not beat it here. tools/check-bun-build.mjs is the control, in the kitesurf:check idiom. Re-run on each Bun release; question 3 is the one most likely to change.
They cannot, and the two halves fail for different reasons. Added as a fourth
question to bun:build:check so it is re-measurable rather than remembered.
JS: bun is ALREADY at maximum. `minify: true` is byte-identical to
{whitespace, syntax, identifiers}, so there is no more aggressive setting;
turning identifier mangling off only makes nav.js worse, 5957 to 6548 brotli.
oxc ships 5783 on the same file. The gap is the minifier, not the configuration.
And oxc's config is not expressible in bun anyway: `mangle: { toplevel: false }`
keeps top-level names because several islands expose globals other site code
finds BY NAME. Bun offers `identifiers` as a single boolean with no top-level
carve-out.
CSS: the fallbacks are unconditional. lightningcss is called with NO `targets`,
which is why it emits none and lands at 36841 bytes against bun's 69806. A
modern .browserslistrc changes that by ZERO bytes, and so does a browserslist
key in package.json. Both locations measured, both ignored.
METHODOLOGICAL NOTE, because it inverts the Kitesurf lesson. That probe worked
because the payload schema was CLOSED, so a bogus key produced an error that
proved what was supported. Bun.build IGNORES unknown option keys, so a missing
error proves nothing. Every row in this control is measured by EFFECT on the
output bytes. Check which kind of schema you hold before designing the probe.
293 contract tests and lint clean; no stray .browserslistrc left behind.
…douts pnpm is gone: no lockfile, no workspace file, no invocation, no message telling anyone to run it. The only mentions left are the two Workers Builds deploy commands in infra.json, which RECORD what is typed into the Cloudflare dashboard rather than anything this repo runs, and they cannot move first because check-infra.mjs compares them against the live dashboard by exact match. wrangler.jsonc's build.command was still `node tools/build.mjs`, so every wrangler deploy shelled back into node for the build. It is bun now, and the deploy path was then verified with node genuinely unavailable (a PATH stub exiting 127): `wrangler deploy --dry-run` completed and produced a BYTE-IDENTICAL bundle, one sha256 across both runs. node survives for exactly two things. The wrangler CLI, because .bin/wrangler carries a `#!/usr/bin/env node` shebang — with the escape hatch measured, since `bun ./node_modules/wrangler/bin/wrangler.js` answered 4.123.0 with node shadowed while `bun x --no-install wrangler` did not (bunx honours the shebang). And routes:check, which boots workerd through createTestHarness() and still hangs under bun on this canary. Both are the wrangler/miniflare/workerd toolchain being node-pinned rather than a bun defect. GZIP IS RUNTIME-DEPENDENT AND BROTLI IS NOT. On identical input bun's node:zlib gzip runs ~0.7-0.8% larger than node's (nav.js 17810 -> 17951, lens.js 44766 -> 45084, luna.css 21940 -> 22100) while brotli q11 is byte-identical on all three. Nothing reaching a visitor changes, because the wire is brotli and zstd; gzip is a reporting path that perf-budget computes itself and wrangler prints in Total Upload. That is why one bundle reported 227.78 KiB under node and 230.10 under bun while hashing identically. Budgets have room, so nothing was re-baselined, but a gzip figure is only comparable to one taken on the same runtime. check-wrangler.mjs had been agreeing with itself. It counted transitive wrangler copies by reading node_modules/.pnpm alone; that directory stopped existing at the pnpm removal, the catch swallowed the ENOENT, and it reported "0 transitive copies" on every run since. It reads .bun and .pnpm now, parses both suffix conventions, and says so when it finds neither. Verified by planting a wrangler@3.0.0 in the store. And a second, older weakness found while fixing it: the count was only PRINTED, so a drifting wrangler exited 0. Survivable while the scan read a missing directory and the count was always zero; with the scan working it would be a scanner nobody reads. Exit 1 now, with the offending versions named. 293 contract tests, typecheck, lint, check-wrangler, pages:check, tools:check, perf-budget and the 136-route oracle all clean.
config/bun-canary.json declared darwin-aarch64 alone, which is the workstation. .github/actions/setup-bun indexes that map by platform and defaults to linux-x64, so CI would have failed at its first step with a TypeError rather than a message. Both are declared and digest-pinned now.
The rebase onto main brought #439/#440/#441 (the 200px tier, packed histograms, the fingerprint index and one fewer copy of the photo data), and that code was written against www/. Four integration fixes it needed: - build.mjs's meta-twin derivation wrote to ${OUT}/www/images/meta and read exif.json + histograms.json from the same place, so it generated 0 files. Its own floor caught it, which is the tripwire doing exactly its job. - the homepage bake lost its `histograms` load in a conflict resolution and failed with a bare ReferenceError. Restored from main. - contract-tests imported ../www/scripts/build-histogram-index.mjs and read www/images/histograms.json. - tooltip.js cited www/scripts/build-histogram-index.mjs in a comment. That last one was caught by the served-path contract test added earlier on this branch, which is the first time it has found something it was not written for. Also un-resurrected 158 files. #441 DELETED www/images/meta and gitignored it: the per-photo twins are derived from exif.json + histograms.json now, and a pure function of committed bytes should not be a second committed copy. The bulk conflict resolution added them back at their moved path, which would have undone that silently. The .gitignore rule and build.mjs's STAGE_SKIP both survive, repointed to public/images/meta. 307 contract tests (main's 12 coverage tests came across), typecheck, lint, check-wrangler, pages:check, tools:check and perf-budget all clean. Bundle 669.37 KiB, up from 666.62 because main's photo work is now in it.
7485578 to
8e05faf
Compare
…ions
add-car-photo.sh is add-car-photo.ts. Output verified BYTE-IDENTICAL against
the shell version on the same input: jpg 28967 bytes, avif 18211 bytes, same
sha256 both ways. The `du -h` figures differ only because du reports disk
blocks.
THE POINT IS NOT ERGONOMICS. In shell a prerequisite is written
for cmd in sips exif-sooc jq; do command -v "$cmd" || ...; done
so the binary name exists only as a loop word, invisible to a search for it.
That is why four prerequisites stayed undocumented until tools:check went
looking, and why that checker carries three source-scraping scanners each with
a FLOOR so a scanner that stops matching cannot report a pass. All of it is
scaffolding around one fact: the shell hid the names.
tools/photos/lib/prereqs.ts makes them values. A converted script exports
`REQUIRES = ["sips", "avifenc"]`, and check-tools.mjs now READS that: no regex,
no floor, and it checks BOTH directions (a binary tools.json does not declare,
and a declaration tools.json left behind). Both verified by control. Install
hints are not duplicated; prereqs.ts builds the message from config/tools.json.
THREE REAL BUGS THE CONVERSION FOUND, none of which the shell could surface:
- add-car-photo has always used sips and avifenc, and NEITHER declared it as a
consumer, because the script named them by absolute path (/usr/bin/sips,
/opt/homebrew/bin/avifenc) rather than through `command -v`. The literal-probe
scanner cannot see a path. Those paths were also simply wrong on any machine
that installs Homebrew elsewhere; Bun.which resolves through PATH.
- the script had been BROKEN since the www/ split: its DEST is
`$(dirname $0)/../cars`, which resolved to tools/cars, a directory that does
not exist. It had to be created by hand to run at all for the comparison.
- .gitignore still said www/scripts/zenc/target/, so 244 MB of cargo build
output was one `git add -A` from being committed. Same extensionless-file
blind spot that hid stale paths in _headers and .assetsignore earlier; that is
three times now, and the lesson is that a sweep filtered on file extension
misses exactly the files that configure the repository. Also repointed the
.venv rule and retired www/ad/, which named nothing after the split.
Scope, stated plainly: this is 1 of 10 scripts, 49 lines of 1846. The shared
module, the REQUIRES convention, the import.meta.main guard (without which the
checker EXECUTES the script it is reading) and the checker tier are the durable
parts; the remaining nine are mechanical against that pattern. add-photos.sh is
last on purpose: 493 lines that touch R2 and the irreplaceable originals, and it
deserves the same byte-comparison discipline this one got.
307 contract tests, typecheck, lint, tools:check and pages:check clean.
Both verified byte-identical against the shell versions. bump-version wrote the same checkpoints.json (and the original was restored); the downloader pulled the same 8,959,603-byte original, and its rejection path prints the same message for the same bad key. THREE PREREQUISITES DISAPPEAR rather than move, which is the part worth noting: - bump-version was bash wrapping a python3 heredoc to edit ONE json file. Bun is both, so python3 leaves that script entirely. - the downloader needed curl to fetch and jq twice, once to read the manifest and once to @uri-encode a key. fetch, JSON.parse and encodeURIComponent are builtins, so both are dropped from its declaration in config/tools.json. exif-sooc stays, because reading image dimensions is genuinely somebody else's job, and that is the line: replace a tool that was standing in for a language feature, keep the one doing real work. One subtlety the conversion had to preserve: python's json.dumps(sort_keys=True) sorts object keys and JSON.stringify does not. Without matching it, every entry this script wrote would order its keys differently from the 189 already in the file and the changelog would churn on every release. serialize() is exported so that rule is testable rather than incidental. Also reverted the sweep's edits to design/, which CLAUDE.md documents as dated design passes rather than spec: renaming a script inside a July-2026 document rewrites history the same way the earlier migration-doc sweep did. 307 contract tests, lint, tools:check and checkpoints:check clean. Three scripts now declare their binaries as values; seven shell scripts remain.
…cript
Verified in a SANDBOX rather than against the real photo tree, because this one
deletes source tiers and prunes public/i/. Synthetic bytes exercising the four
paths that matter: the merge into an existing map, the prune of a superseded /i/
file, an additive run writing only a -200 tier (which a JPG-only stem scan
misses), and the compact sorted JSON python wrote. Identical tree and identical
map, both before and after the lint fixes below.
THIRD SCRIPT THE www/ SPLIT BROKE, for the same invisible reason. It computed
HOLDING as "$(dirname $0)/..", which was www/ when the file lived at
www/scripts/ and became tools/ afterwards, so SRC_DIR pointed at tools/images
and the fingerprint call at tools/scripts/, both missing. No search for `www/`
finds either, because the path is COMPUTED rather than written. add-photos and
add-car-photo carry the same defect and are still to come.
python3 leaves this script too, so it is down from five consumers to three.
Two lint findings worth naming, because both are byte-relevant rather than
cosmetic. `.sort()` on the map keys needed an explicit comparator, and it must
be CODEPOINT order rather than localeCompare: python's sorted() is codepoint
order and this map is compared byte-for-byte, so a locale-aware collation would
reorder keys and churn hashes.json on every run. And `{ ...(map[stem] ?? {}) }`
was a useless fallback, since spreading undefined adds nothing.
307 contract tests, lint and tools:check clean. 4 of 10 converted.
…an too late gen-encoding-grids and gen-encoding-samples are TypeScript. Verified in scratch directories rather than against the committed study assets, because regenerating those is a deliberate act with consequences the grids script documents at length. 16 outputs identical for the grids, 11 for the samples. A REAL BUG FIXED IN THE MOVE. gen-encoding-samples ran its exif-sooc VERSION GATE at the very end, after every write that depends on it. A too-old exif-sooc strips nothing (it reads `-all=` as a tag SELECTION and prints JSON), and 0.1.0 truncated progressive JPEGs at their first scan; every JPEG here is progressive. So the script would publish damaged or unstripped files and only then announce that the binary was unusable. Its sibling runs the same gate first, which is where it belongs and where this one runs it now. `--dest` is new on both, and it is what made the verification possible: without it the only way to exercise these is to overwrite the committed grids. The version comparison is shared rather than duplicated. It also keeps the shell's sharpest rule: a version that is not plain x.y.z is REFUSED rather than compared, because `sort -V` will happily order a word against a version and answer, so a garbled --version reads as new enough. Fourth split-broken path found on the way: gen-encoding-grids computed DEST as "$(dirname $0)/../garage/enc", which became tools/garage/enc. 307 contract tests, lint and tools:check clean. 6 of 10 converted; the four remaining are extract-photo-metadata, reencode-thumbnails, export-for-instagram and add-photos.
…k reality The jq projection is a typed function now, verified against real jq over all 158 committed records: identical, every file. It took two passes, and the miss is worth recording. The first run differed on all 158 by exactly ONE BYTE: `jq -c` emits a trailing newline and JSON.stringify does not. Invisible in the content, invisible in a spot check, and it would have rewritten every per-stem file the first time this ran for real. jq leaves this script with the filter. exif-sooc stays, because reading EXIF is real work rather than a language feature standing in for one. Fifth and sixth split-broken paths fixed here: META_DIR was "$SCRIPT_DIR/../images/meta" and the histogram bake ran with --root "$SCRIPT_DIR/..", both resolving to tools/ rather than the served tree. AND THE CHECKER'S OWN FLOOR FIRED, correctly, which is the interesting part. check-tools carries constant floors so a source scanner that stops matching cannot report a pass. Converting the seventh script dropped the brew-hint count below 4 and the check failed — not because anything was wrong, but because there is genuinely less shell to scan every time one of these lands. A constant was the wrong shape for a shrinking population. The floors are derived from the number of .sh files LEFT now, so they fall as the conversion proceeds and, when the last one goes, both scanners retire themselves rather than passing on an empty set. 307 contract tests, lint and tools:check clean. 7 of 10.
reencode-thumbnails: verified against two real originals through all four tiers, 8 outputs byte-identical. It is the script whose lossless-intermediate geometry is load-bearing (median ssimulacra2 68.97 -> 80.20 over the corpus, better on 159 of 159, for 1.1% more bytes), so the TIFF-then-PNG chain and the short-edge-then-crop order are preserved exactly. export-for-instagram: verified on both branches of its control flow, which matters because the interesting one only runs when the target is reachable. The MISS path produced identical output and an identical file; the SEARCH path binary-searched to the same q51, the same ssimulacra2 50.12 and butteraugli 4.446, the same 23.7 KB and the same "-68% vs q95". Same file bytes. Its two metrics keep their fallback into /opt/zerobrew/prefix/bin, and the error message now says WHY: these are libjxl TOOLS built with -DJPEGXL_ENABLE_TOOLS=ON, and brew's jpeg-xl formula does not ship them. The old message named the formula, which sends you to install something you already have. cargo's attribution moved to lib/prereqs.ts rather than being repeated on every converted script, since ensureZenc() is the only thing that shells out to it. Its required_by went from 6 entries to 2. 307 contract tests, lint and tools:check clean. 9 of 10; add-photos is the last one, and the only one left that touches R2.
reencode-thumbnails, export-for-instagram and add-photos are TypeScript, and tools/photos holds no .sh. Every one of the ten was verified against the script it replaced BEFORE that script was deleted. reencode: 2 real originals through 4 tiers, 8 outputs byte-identical, re-checked after the lint fixes. export-for-instagram: both branches of its control flow, which matters because the interesting one only runs when the target is reachable — the MISS path and the binary SEARCH path each produced identical output, identical metrics (q51, ssimulacra2 50.12, butteraugli 4.446) and identical bytes. ADD-PHOTOS WAS NOT RUN END TO END, deliberately. Phase 3 uploads to R2 and phase 4 rewrites public/i and the photo index, so a verification run would have had to modify the real photo tree to prove anything. What was verified is every piece REIMPLEMENTED rather than translated: the index merge against real jq on the real 158-entry index (uploaded preserved for existing entries, minted for new, keys sorted), and the argument and prerequisite handling against the shell. Its geometry is shared with reencode-thumbnails, which WAS verified on real photos. THE THREE SHELL SCANNERS ARE DELETED. They existed because shell hid binary names in loop words, and each carried a floor so it could not pass on an empty match. With no shell left they would report "0 scanned" and pass, which is the decoration this file's own comments warn about everywhere else. Ten scripts export REQUIRES and the checker reads it, both directions, verified by control. SEVENTH SPLIT-BROKEN PATH: add-photos ran the histogram bake with --root "$PROJECT_DIR/www", a directory that stopped existing. Every one of these was a root computed as $(dirname $0)/.., which no search for `www/` can find. Two bugs fixed in flight. gen-encoding-samples ran its exif-sooc version gate AFTER every write depending on it, so a too-old binary would publish damaged files and only then say so. And my own first draft of add-photos had inputs.find(async …), which returns the first element whatever it is. A REGRESSION FROM THE SPLIT, caught here: generate-search-index had ROOT doing double duty as scan root AND output path, so it wrote a served asset into src/pages and dropped 6 records. It scans src/pages, src/content and public now and emits public/search-index.json — 53 records, the same set as before the split, diffed against HEAD. python3 is down from five scripts to two; curl and jq left entirely. The line held throughout: replace a tool standing in for a language feature, keep the one doing real work. 307 contract tests, typecheck, lint, check-wrangler, pages:check, tools:check, perf-budget and the 136-route oracle all clean. Bundle 669.37 KiB.
…add-photos Ran add-photos on one real photo (XT509338.HIF) with REMOTE_RENDER_ONLY=1, so phases 1, 2 and 4 exercised the real photo tree and phase 3 wrote nothing to R2. IT WORKED, and the strongest result is that it was IDEMPOTENT: all four published tiers for that stem came back at the same content hashes (XT509338.924c25aa.jpg, .f31dabeb.avif, -400.5e2aafdf, -200.e2e3b725). The re-encode is deterministic and the pipeline does not churn what it re-processes. photo-index changed exactly one entry and PRESERVED its `uploaded` stamp from 2026-07-20, which is the merge rule verified against jq now confirmed on real data. But the first run died three times, in scripts I never converted, all with the same defect the other seven had: build-histogram-index.mjs IMAGES = ROOT + "www/images" gen-alt-text.py ROOT = dirname(dirname(file)), which was www/ check-photo-pipeline.mjs imports ../_worker.js/lib/parse.js That is eight, nine and ten. Every one a path COMPUTED rather than written, so no search for `www/` finds them, and none of the .mjs/.py helpers were exercised by any earlier check. Running the thing is what found them. TWO PRE-EXISTING BEHAVIOURS worth knowing, neither caused by the conversion and both reproduced from the shell: - Given a FILE, META_SRC becomes its DIRNAME, so metadata is re-merged for the whole source folder. Running this on one photo pulled two unpublished stems (XT500084, XT500088) into metadata.json. - The histogram bake is BUILD-DEPENDENT. All 158 entries changed, and a control showed the bake is stable across runs on this build, so the committed set came from a different zenc binary. target/ is gitignored, so every machine rebuilds and can rewrite all 158 histograms without any input changing. The test churn is reverted: public/images and photo-index.json are back at HEAD, meta/ regenerated from the committed indexes through build.mjs's own derivation, and photos:check reports 158 photos, 632 hashed tiers, index in bijection. 307 contract tests, typecheck, lint, tools:check and photos:check clean.
Ran add-photos on XT509338.HIF with phase 3 ENABLED. The upload is verified against production rather than against the exit code: before content-length 22349573 etag def60e4e401c7c69e4f5acb12525d2f4 after content-length 22348193 etag 866811b9af3a0186728e1c7141064c9b So every phase of the converted pipeline has now been exercised end to end, and the last unverified piece was two wrangler calls. THE 1380-BYTE DIFFERENCE IS REAL AND IS WHY THIS COMMIT EXISTS. Phase 2 re-exports the HIF to a q100 4:2:2 JPG with the CURRENT zenc, and the copy in R2 was made in July by an older build. The new export is 1380 bytes smaller, so uploading it made src/worker/photo-index.json's recorded size wrong by that much — the index reports what /images/full serves, and reverting it would have left the site quoting a stale figure for a file it had just replaced. The size is kept; index and R2 both read 22348193 now. Everything else the run touched is reverted, because none of it was a real photo add: metadata.json had picked up two unpublished stems from the source folder (the META_SRC-is-the-dirname behaviour), and exif.json plus histograms.json followed it. meta/ is regenerated from the committed indexes through build.mjs's own derivation. photos:check reports 158 photos, 632 hashed tiers, index in bijection, complete EXIF + histogram + alt-text metadata. 307 contract tests, lint and tools:check clean.
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
aadhar-sh | cdade3f | Aug 18 2026, 03:31 AM |
Running add-photos on one photo rewrote all 158 histograms with nothing about the input having changed. Investigated rather than normalised, because "adding one photo rewrites 158 files" is how a real defect gets hidden. RULED OUT: Cargo.lock is committed and unchanged (image 0.25.10, zune-jpeg 0.5.15); the bake reads public/i/<stem>.<j>.jpg through hashes.json and those hashes are unchanged, so the input bytes are identical; two consecutive bakes on this build are byte-identical, so it is not nondeterminism. The remaining variable was the BUILD, and there was no rust-toolchain file. MEASURED across all 40,448 bins: 31.2% differ. 62% of the differences are one level and 20% are two, which is the decoder noise histogram.rs's header measures and correctly says binning absorbs. The worst is 37 levels, and that is not absorbed by anything. THE MECHANISM IS IN THE PACK. The histogram is PEAK-NORMALISED — `100 * b / max(binned)` — so a decoder difference that nudges the peak bin rescales all 64 bars at once. That is how a 0.07% pixel disagreement becomes a 31% bin disagreement. Two fixes, both chosen because they remove a variable without moving a served byte: rust-toolchain.toml pins rustc 1.93.0, and ensureZenc builds with --locked so a build cannot silently update the committed lock. It could before, which would have moved every histogram with no visible cause at all. NEITHER FIXES THE EXISTING MISMATCH, deliberately. The committed set came from an unrecorded build, so no pin reproduces it retroactively; closing the gap needs one deliberate rebake that moves served histogram bytes for all 158 photos, and that is the owner's call rather than a side effect of a tooling branch. The deeper fix, if it ever costs more than one rebake, is to normalise against a stable denominator instead of the peak. photos:check reports 158 photos, 632 hashed tiers, index in bijection. 307 contract tests, lint and tools:check clean.
Closes the drift the previous commit diagnosed. `zenc histogram --check` now reports "histograms match on disk for 158 photos"; it reported 158 of 158 DRIFTING before. This is the one deliberate rebake that commit said was needed. The committed set came from an unrecorded build, so no pin could reproduce it retroactively — the gap only closes by re-baking once and committing the result. Done under rust-toolchain.toml (rustc 1.93.0, override confirmed active) and a --locked build, so this set IS reproducible from the repo from here on. WHAT MOVED: 12,635 of 40,448 bins, 31.2%, worst delta 37 levels. Same numbers the investigation measured, which is the point — this is that difference being accepted rather than a new one. 62% of it is one level, the decoder noise the format was always going to carry; the tail is the peak normalisation amplifying it, and that is unchanged by this commit. BLAST RADIUS, measured against a pre-rebake build manifest: 164 staged files. 158 are the derived per-photo twins, which are gitignored and rebuilt from this index. The rest are images/histograms.json, index.html (the homepage bakes histograms into the grid since #441) and its 3 page deltas. NO /a/ asset changed, so no content-hashed URL was re-minted and no a-dict entry orphaned. One committed file: public/images/histograms.json. The peak-normalisation fragility is NOT addressed here and is the open item: a stable denominator would make the bake robust to the decoder differences histogram.rs already documents as unavoidable. That changes the format and every histogram once more, so it stays a separate decision. photos:check reports 158 photos, 632 hashed tiers, index in bijection. 307 contract tests, lint, typecheck, tools:check, pages:check, perf-budget and the 136-route oracle all clean.
The pin fired the day after it shipped, which is what it is for. CI stopped at Set up bun naming both digests; a workstation fetch minutes later hashed the same linux-x64 value the runner got, so the asset had moved rather than being served inconsistently. The version string and packageManager do not change, because neither of them moved. That is the whole reason the declaration is a SHA-256. CONTROLS, all run before this digest was committed: new canary vs old canary 1499 files, 0 differing node v26.7.0 vs new canary 1499 files, 0 differing zstd dictionary probe none=65 good=18 wrong=64, unchanged contract / cal / lens-reader 307 / 59 / 13 pass routes 136 routes, 0 hard failures typecheck, lint, pages:check, tools:check, check-wrangler, perf-budget clean The node comparison is the one that carries the weight. Two bun builds share a compiler lineage, so their agreement is weaker evidence than agreement with the runtime every committed hash was minted under.
Step 7c writes the sha256 map the enforcing script-src is built from, and it read HTML with a hand-rolled tag walker. That walker was correct only because it had been patched three times against the served bytes: minify-html unquotes attributes and decodes character references inside quoted values, so a scanner written against authored HTML reads minified HTML wrong. CLAUDE.md records the three incidents, one of them on this exact step. bun ships HTMLRewriter, which is lol-html: THE SAME PARSER src/worker/ runs. So the build now reads a document the way the Worker does and the way the browser computing these hashes does. node has no equivalent and cannot get one without a dependency. IT CHANGES NO OUTPUT, which is the assertion that matters. 48 pages parse in 35ms and reconcile exactly with what the walker found: 50 untyped + 46 speculationrules + 1 module executable in the documents, plus 1 inside garage/horizon's iframe[srcdoc], is the 98 this step has always reported. 31 data blocks (application/json, ld+json) are correctly not hashed. A full build is BYTE-IDENTICAL across all 1499 files. TESTS, five, written for teeth rather than agreement: data blocks excluded and speculationrules included; the hash pinned to a literal over the RAW body, so the chunk split on `<` and an `&` in a script body both stay honest; srcdoc descended IN DOCUMENT ORDER, asserted against the flattened equivalent, since the map is serialised into csp-hashes.ts and a reshuffle is a changed bundle; the horizon `value="<img onerror=…>"` false positive; and the empty and unterminated blocks. Two controls run and reverted, each failing by name. ONE RUNTIME. The build can no longer run under node, so dictionary-roll.yml's build step moves to `bun run build` (it already sets up bun and installs with it; the node line was a leftover). The guard runs BEFORE the first rm(OUT) rather than at 7c, where it would fire two seconds in with the previous build already deleted: the same lesson bun-canary.json records about the zstd probe being correct and 40 seconds late. Also corrects two package.json comments that still quoted the build.command as `node tools/build.mjs`.
Every per-photo loop in the photo tooling ran ONE photo at a time on a 14-core machine, at about 85% of a single core. Phase 1 alone spawns 6 sips, 1 zenc and 3 avifenc per photo, measured at 1.1s for the chain. tools/photos/lib/pool.ts is shared by add-photos.ts (phases 1 and 2, plus the uploads), reencode-thumbnails.ts and export-for-instagram.ts. THE NUMBER IS MEASURED, 24 real photos through the phase-1 chain: serial 836 ms/photo 8 159 ms/photo 4 228 ms/photo 10 163 ms/photo 6 184 ms/photo 12 150 ms/photo The knee is 8 and past it the differences are run-to-run noise, since avifenc already takes --jobs 4. PHOTO_JOBS overrides it; uploads keep UPLOAD_JOBS of 4 because R2 bounds them rather than the CPU. End to end on the heaviest loop, 30 files through export-for-instagram --dry-run: 33.7s to 6.2s. TWO CONTROLS, and the second found a real bug. Encoders first, because /i/ is content-addressed and a moved byte re-mints a URL: 8 photos through the full chain serially and pooled, all 32 encoded outputs byte-identical. No encoder flag was touched. Then stdout, which is where the bug was. The pooled export printed different ssimulacra2 scores than the serial run, because intermediates were keyed by STEM and two inputs can share one. That is a harmless overwrite at one photo at a time and a race at eight. The pool hands each item its INDEX, so intermediates are per item; reencode keeps stem-keyed temps because its stems are hash-map keys and unique by construction, and that invariant is now written at the loop. Generalises: parallelising a loop makes every shared name a race, and the shared names are usually temp files nobody thinks of as state. A QUEUE RATHER THAN BATCHES. The old helper put a barrier at every batch, so nobody started item n+1 until the slowest of the first n finished, and photos are not uniform. Four tests pin the pool; the barrier one FAILS against the old batching helper, which is what makes it worth having. export-for-instagram buffers each photo's lines and flushes them in INPUT order, so its aligned table reads exactly as it did serially. Verified by diffing stdout against the pre-change run. COVERAGE, separately, since bun ships it free. `bun run test:coverage` names what the 316 contract tests never execute: 7707 of 14063 lines (54.8%), with updates.ts at 4.7%, check-agent.mjs at 11.3%, whoareyou.ts at 22.0%. CI runs it in validate under continue-on-error and puts the table in the job summary. It GATES NOTHING on purpose: a coverage floor is one more constant somebody widens, and perf-budget already spent an era in breach printing "hard checks green" over itself. cal/ is counted out and named, since its Vitest suite owns those files and they would otherwise read as untested. Build still byte-identical across 1499 files. 316 tests, lint, typecheck, routes, pages:check and tools:check clean; tools:check is what caught a scratch file left in tools/photos.
Second roll in one day. Same controls as this morning: build byte-identical across all 1499 files against the previous canary, zstd dictionary probe unchanged at none=65 good=18 wrong=64, 316 contract tests, lint and typecheck clean. Two bumps in a day is the shape of tracking a rolling tag rather than bad luck, so the doc now says so: expect to re-bump on most days this branch is open, and expect anyone arriving cold to find validate red on the setup step rather than on the diff.
zstdCompressSync's `dictionary` option is the ONE api on this branch that bun 1.4 has and released bun does not. Under bun 1.3.14 the build runs staging, minification, the /a/ hashing, precompression and the CSP scan, and dies at that probe alone. So zstd-batch.mjs delegates rather than demanding a canary: when the host ignores the option the batch goes to node, which honours it from 26. CONTROLS, all byte-identical across 1499 files: canary, in-process (unchanged) 0 differing canary, delegation FORCED 0 differing released bun 1.3.14, end to end 0 differing Plus 318 contract tests under both runtimes, lint and typecheck clean. Build wall clock 2.1s in-process, 3.1s delegated. THE FORCED CONTROL FOUND A SECOND CALL SITE, which is why it exists. The first version routed only the batch, and under released bun the build then SUCCEEDED while shipping 21 fewer files: the shell tier called zstdCompressSync directly and produced no deltas while the page tier produced 144. The log said only that every candidate "lost to plain brotli", which is also what it says when the shell has not changed. Same silent shape as the original dcz bug: a runtime that ignores the option still returns a valid frame, that frame still decodes, and the only symptom is a byte count that never shrank. The build's capability gate now asks whether this BUILD can make dictionary frames, here or through the delegate, rather than whether this runtime can. It is still a hard stop when neither can, and it names which one it got. This does not by itself unblock Workers Builds; it removes the canary as a REQUIREMENT, so adopting one there becomes a choice rather than a precondition.
THE LOCKFILE IS WHAT BLOCKED THE DEPLOY, not the build. bun 1.4 writes
lockfileVersion 2 and released bun cannot parse it, measured twice in real
Workers Builds runs:
bun install v1.3.14
"lockfileVersion": 2,
error: Unknown lockfile version at bun.lock:2:22
error: lockfile had changes, but lockfile is frozen
That is gotcha 30 with the roles swapped: pnpm 12 wrote a lockfile pnpm 10 could
not read, which is why this repo is not on pnpm 12.
NEITHER LEVER REACHES A CANARY, three builds, so the image cannot be made to
match the workstation. With packageManager: bun@1.3.14 and BUN_VERSION=canary
set, the image installed 1.3.14 (the field wins, the variable is ignored). With
packageManager: bun@canary it reported bun@1.2.15 and no "Installing bun" line
at all, silently taking the image default. It resolves a RELEASED version or
nothing.
THE ASYMMETRY RESOLVES IT. bun 1.3.14 writes v1 and reads v1; the canary writes
v2 and reads BOTH. So one v1 lockfile serves the image and the workstation, and
the two pins are now deliberately different: packageManager names bun@1.3.14
because that is the only string the image resolves, while config/bun-canary.json
keeps pinning the canary by digest. The contract test that asserted the two were
equal now asserts the split, with the measurement at it.
configVersion is NOT part of it, measured rather than guessed: released bun
parses the file with configVersion 0, with 1, and with the key deleted. My first
assertion said the key belonged to v2 and failed on the next regeneration.
TRIPWIRE, because the regression is silent: a plain `bun install` under the
canary rewrites the file to v2, and the next thing to notice would be a failed
production deploy. A test pins lockfileVersion 1 and names the fix.
IMAGE SIMULATED END TO END: released bun 1.3.14, frozen install from the v1
lockfile into a deleted node_modules, then wrangler's entry run under bun with
node 26 present. Byte-identical across all 1499 files, with the deltas going
through the node delegate.
Also bumped @cloudflare/workers-types to 5.20260818.1, the one package behind
latest. Types only: typecheck clean, build byte-identical.
319 tests, lint, typecheck, 136 routes clean.
Four routes were measured; one is taken, one is declined on its merits, and two are impossible. The file now says which is which, what 1.4 GA collapses, and the checklist for that day, so the three probe builds behind these findings do not get run again by whoever picks this up. Route B (SKIP_DEPENDENCY_INSTALL plus a self-installed canary) stays unbuilt on purpose rather than unnoticed: it is the only route that puts a network fetch of a COMPILER on the path that publishes production, and the rolling tag makes it fail closed on the days it rolls, which has been most days.
Moves tools/bun-image-probe.mjs off the throwaway bun/image-probe branch onto the branch it produced findings for, so the instrument survives once that branch is deleted. It answered three questions on 2026-08-18, and every one has to be asked again the day bun 1.4 goes GA: which bun the build image resolves, whether the lockfile it wants parses, and what node it carries. Point wrangler.jsonc's build command at it on a throwaway branch to re-run; it exits 1 on purpose so wrangler uploads nothing. The branch-local hacks that made it run, the packageManager edits and the build command swap, deliberately do not come with it.
`www/_worker.js/` is `src/worker/`. It sits beside cal/ and serendipity/ now, for the reason CLAUDE.md's layout table already gave for those two: it is a program with its own tests, not a document. `www/` is meant to be the things a browser can fetch, and the Worker was never one of them (`.assetsignore` has always listed `_worker.js`). TOOLCHAIN-INDEPENDENT, and lifted out of the parked bun branch (#443) on purpose. That branch is 1229 files against main and 1128 of them are pure RENAMES, none of which need bun. Landing the moves separately, under the toolchain we are staying on, means the eventual migration is a small diff against a settled layout rather than one change that swaps the runtime AND reshapes the tree. This is the first slice. NO SERVED BYTE CHANGES, which is the bar. Built both trees under node with pnpm and compared all 1542 served artifacts: byte-identical. The staged output moves 70 files from .build/www/_worker.js/ to .build/src/worker/, and five files carry a changed path in a COMMENT (cal/src/{availability,templates,trace,workflow}.js and serendipity/serendipity.js, all naming the Worker's old location). The staged path mirrors the source path, and that is load-bearing rather than tidy: cal and serendipity import the Worker across the project boundary and are bundled from .build/, so a relative specifier has to resolve in BOTH trees, which only works when the two have the same shape. Gates on main's own toolchain: 305 tests, lint, typecheck, pages:check, routes:check (136 routes), check-wrangler, perf-budget, and a wrangler dry-run that resolves the moved `main` and `assets` paths. CLAUDE.md's layout table and MAINTENANCE.md come along, since a table that names a directory which no longer exists is worse than no table.
…450) * refactor: the Worker is a program, so move it out of the served tree `www/_worker.js/` is `src/worker/`. It sits beside cal/ and serendipity/ now, for the reason CLAUDE.md's layout table already gave for those two: it is a program with its own tests, not a document. `www/` is meant to be the things a browser can fetch, and the Worker was never one of them (`.assetsignore` has always listed `_worker.js`). TOOLCHAIN-INDEPENDENT, and lifted out of the parked bun branch (#443) on purpose. That branch is 1229 files against main and 1128 of them are pure RENAMES, none of which need bun. Landing the moves separately, under the toolchain we are staying on, means the eventual migration is a small diff against a settled layout rather than one change that swaps the runtime AND reshapes the tree. This is the first slice. NO SERVED BYTE CHANGES, which is the bar. Built both trees under node with pnpm and compared all 1542 served artifacts: byte-identical. The staged output moves 70 files from .build/www/_worker.js/ to .build/src/worker/, and five files carry a changed path in a COMMENT (cal/src/{availability,templates,trace,workflow}.js and serendipity/serendipity.js, all naming the Worker's old location). The staged path mirrors the source path, and that is load-bearing rather than tidy: cal and serendipity import the Worker across the project boundary and are bundled from .build/, so a relative specifier has to resolve in BOTH trees, which only works when the two have the same shape. Gates on main's own toolchain: 305 tests, lint, typecheck, pages:check, routes:check (136 routes), check-wrangler, perf-budget, and a wrangler dry-run that resolves the moved `main` and `assets` paths. CLAUDE.md's layout table and MAINTENANCE.md come along, since a table that names a directory which no longer exists is worse than no table. * fix: repoint check-photo-pipeline at the Worker's new home check-photo-pipeline.mjs imports asRecord/asText from the Worker's parse helper, and the move left that specifier at ../_worker.js/lib/parse.js, which no longer exists. CI caught it at "Check photo asset invariants" with ERR_MODULE_NOT_FOUND. MY BATTERY MISSED IT because I ran the gates I remembered rather than the ones ci.yml runs: photos:check was not among them, and it is the only step that loads this file. Re-ran the full list this time, all twelve steps green, including the two dry-runs and the cal suite. It is also the one dangling import the move left. Every other surviving mention of _worker.js in the tree is prose: comments in lens.js, tooltip.js, lens-browser.js, lens-tools.js and lens-reader explaining why those files cannot import it, plus two entries in the generated search index. --------- Co-authored-by: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com>
Workers Builds holds a SINGLE command string per trigger, and it has to work on whatever branch is being built. `main` is a pnpm tree; #443 makes it a bun tree, and the two cannot share an invocation: pnpm exec wrangler REFUSES on a bun tree. pnpm reads packageManager and exits "This project is configured to use bun" (reproduced on pnpm 11.22.0; the image ships 10.11.1, which enforces the same field) bun .../wrangler.js cannot run on a pnpm tree in the image, which installs bun only when the tree asks for it Without this, the dashboard string can only ever suit one branch, so the migration would need the dashboard edited at the exact moment it merges with every other branch's build broken on one side of the switch. The LOCKFILE decides, not what is on PATH: the image preinstalls a bun of its own, so probing `command -v bun` would quietly run main's production deploy under a bun this tree never asked for. It takes NO opinion on wrangler's arguments. Everything after the script name is passed through, so `versions upload` and both provisioning flags stay in the dashboard string where check-infra.mjs already reads them, and that check needs no change. It never fetches. npx/bunx/dlx resolve missing binaries from the registry (gotcha 29), which on the one path that publishes production means deploying with a wrangler nobody pinned. A test greps for those tokens rather than for a phrase, since the sweep that closed that hole missed `npx` as a quoted argument. It runs wrangler's ENTRY FILE rather than `bun x wrangler`: measured 2026-08-18 with node replaced by a stub exiting 127, `bun x --no-install wrangler` invokes node through the `#!/usr/bin/env node` shebang and fails, while the entry stays inside bun. TESTED BY RUNNING IT against fixture trees with stub binaries: both branches, plus the three loud failures (no lockfile, missing wrangler entry, no arguments). Control run: breaking the lockfile branch fails the test by name. No dashboard change is required by this commit. It lands the script so the dashboard CAN be pointed at it, which is a separate step, with infra.json's strings following after.
Workers Builds holds a SINGLE command string per trigger, and it has to work on whatever branch is being built. `main` is a pnpm tree; #443 makes it a bun tree, and the two cannot share an invocation: pnpm exec wrangler REFUSES on a bun tree. pnpm reads packageManager and exits "This project is configured to use bun" (reproduced on pnpm 11.22.0; the image ships 10.11.1, which enforces the same field) bun .../wrangler.js cannot run on a pnpm tree in the image, which installs bun only when the tree asks for it Without this, the dashboard string can only ever suit one branch, so the migration would need the dashboard edited at the exact moment it merges with every other branch's build broken on one side of the switch. The LOCKFILE decides, not what is on PATH: the image preinstalls a bun of its own, so probing `command -v bun` would quietly run main's production deploy under a bun this tree never asked for. It takes NO opinion on wrangler's arguments. Everything after the script name is passed through, so `versions upload` and both provisioning flags stay in the dashboard string where check-infra.mjs already reads them, and that check needs no change. It never fetches. npx/bunx/dlx resolve missing binaries from the registry (gotcha 29), which on the one path that publishes production means deploying with a wrangler nobody pinned. A test greps for those tokens rather than for a phrase, since the sweep that closed that hole missed `npx` as a quoted argument. It runs wrangler's ENTRY FILE rather than `bun x wrangler`: measured 2026-08-18 with node replaced by a stub exiting 127, `bun x --no-install wrangler` invokes node through the `#!/usr/bin/env node` shebang and fails, while the entry stays inside bun. TESTED BY RUNNING IT against fixture trees with stub binaries: both branches, plus the three loud failures (no lockfile, missing wrangler entry, no arguments). Control run: breaking the lockfile branch fails the test by name. No dashboard change is required by this commit. It lands the script so the dashboard CAN be pointed at it, which is a separate step, with infra.json's strings following after.
Workers Builds holds a SINGLE command string per trigger, and it has to work on whatever branch is being built. `main` is a pnpm tree; #443 makes it a bun tree, and the two cannot share an invocation: pnpm exec wrangler REFUSES on a bun tree. pnpm reads packageManager and exits "This project is configured to use bun" (reproduced on pnpm 11.22.0; the image ships 10.11.1, which enforces the same field) bun .../wrangler.js cannot run on a pnpm tree in the image, which installs bun only when the tree asks for it Without this, the dashboard string can only ever suit one branch, so the migration would need the dashboard edited at the exact moment it merges with every other branch's build broken on one side of the switch. The LOCKFILE decides, not what is on PATH: the image preinstalls a bun of its own, so probing `command -v bun` would quietly run main's production deploy under a bun this tree never asked for. It takes NO opinion on wrangler's arguments. Everything after the script name is passed through, so `versions upload` and both provisioning flags stay in the dashboard string where check-infra.mjs already reads them, and that check needs no change. It never fetches. npx/bunx/dlx resolve missing binaries from the registry (gotcha 29), which on the one path that publishes production means deploying with a wrangler nobody pinned. A test greps for those tokens rather than for a phrase, since the sweep that closed that hole missed `npx` as a quoted argument. It runs wrangler's ENTRY FILE rather than `bun x wrangler`: measured 2026-08-18 with node replaced by a stub exiting 127, `bun x --no-install wrangler` invokes node through the `#!/usr/bin/env node` shebang and fails, while the entry stays inside bun. TESTED BY RUNNING IT against fixture trees with stub binaries: both branches, plus the three loud failures (no lockfile, missing wrangler entry, no arguments). Control run: breaking the lockfile branch fails the test by name. No dashboard change is required by this commit. It lands the script so the dashboard CAN be pointed at it, which is a separate step, with infra.json's strings following after. Co-authored-by: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com>
* build: move the toolchain to bun 1.4 bun-v1.4.0 shipped today, which is the one thing #443 was parked on. This is that branch rebuilt as TOOLCHAIN ONLY: 20 files, against 1229. Everything separable landed on main first as seven restructure slices and the TypeScript conversion, so what is left is the package manager and the CI that runs it. BYTE-IDENTICAL TO WHAT MAIN BUILDS TODAY, 1505 staged files, verified three ways because the release notes gave three reasons to doubt it: node + pnpm the baseline, main as it stands bun 1.4 + pnpm the runtime alone, on pnpm's dependency tree bun 1.4 + bun both, on bun's own resolutions 1.4 is a Zig-to-Rust rewrite and switches node:zlib to zlib-ng, and this repo's output is content-addressed and almost entirely compression-derived, so a single moved byte would re-mint /a/ and /i/ URLs and orphan every committed dictionary. Nothing moved. oxc-minify, lightningcss and minify-html resolve to the same pinned versions under both installers. THE CANARY MACHINERY IS GONE, and that is what a released tag buys. config/ bun-canary.json pinned a SHA-256 because the `canary` tag rolled daily, which would have swapped a compiler underneath the artifact graph. A released tag is immutable, so .github/actions/setup-bun reads the version from packageManager and installs it. One declaration, no digest to bump, no daily red CI. It still probes the capability first. Every bun before 1.4 ACCEPTS zstdCompressSync's `dictionary` option and silently ignores it: the frame decodes, nothing throws, and the only signal is a byte count that never shrank. The action runs that control before any build spends 40 seconds discovering it. TWO TESTS REPLACED RATHER THAN DELETED. The workflow-bootstrap test now asserts every workflow that RUNS bun reaches the shared action and that none names a version inline, and the allowBuilds test becomes trustedDependencies, whose entries must appear in bun.lock. Its first draft demanded a bootstrap from promote-production.yml, which only ECHOES "bun run deploy:promote" as advice, so detection reads commands rather than strings. Also measured rather than carried over: lens-reader needs NO --ignore-workspace. pnpm walked up to the workspace root and never created the local node_modules; bun installs the local package.json on its own. The comment says so, so nobody adds a flag looking for parity. routes:check stays on node deliberately: it boots workerd through wrangler's createTestHarness, which hangs under bun. Gates under bun 1.4: 325 tests, build, lint, typecheck, routes:check, pages:check, tools:check, perf-budget, photos:check, check-wrangler, the cal suite, lens-reader's 13, and a wrangler dry-run. * fix: two runner mistakes the sweep made, both caught by CI `bun test` with no path DISCOVERS every *.test.* in the tree. The CI step ran that, so it collected 367 tests across 10 files, including cal's vitest specs and lens-reader's, under a runner neither expects: 20 failures and 4 errors that say nothing about this change. It calls `bun run test` now, which names the one file the step means. And `node --test X` became `bun --test X`, which is not bun's spelling. The runner is `bun test X`; the flag form silently does something else. Both came from the same blunt substitution, and neither reproduced locally because I ran the suite by PATH rather than through the script and the CI step. Run the command CI runs, not the command that means the same thing. Also restores test:node, deleted by the same sweep. Running the suite under the other runtime occasionally is how gotcha 28 found a real defect: bun's spec-strict Response rejects a body that undici allows, and the test pushing the same case objects through twice was the bug. 325 tests under both runners, and the whole battery green under bun 1.4. * ci: perf-diff installs for the tree it checked out This job builds TWO commits in one run, and across a package-manager migration those two want different installers. It ran `bun install --frozen-lockfile` against the pnpm merge base, which wrote an untracked bun.lock and edited package.json, and the switch back to HEAD then aborted: error: Your local changes to the following files would be overwritten by checkout: package.json So it failed on git bookkeeping rather than on anything it measures. I called this a frozen-lockfile error when I first read it; the frozen-lockfile problem is real and upstream of this one, and the actual abort is the dirty tree. Both steps now pick the installer from the lockfile in the CHECKED-OUT tree, and the base tree is reset before switching back, so an install can no longer leak across the boundary. The helper is written to a file the steps SOURCE rather than exported through BASH_ENV: sourcing is visible where it is used and does not depend on how Actions invokes bash. It also watches pnpm-lock.yaml alongside bun.lock when deciding whether the lockfile moved, since during the swap the answer lives in whichever file exists. Both branches exercised locally against a real bun tree and a real pnpm tree. The job still gates nothing; this only stops it reporting a failure that says nothing about the diff. * fix(perf): the wire-size job was measuring nothing, and passing perf-snapshot spawned "pnpm" as a quoted argument to run the dry-run. That is gotcha 29's blind spot exactly: every sweep for `pnpm exec` as a phrase missed it, so it survived the toolchain swap and then failed instantly on a tree with no pnpm. The runner has no bare pnpm either (only corepack), so it failed on BOTH sides of the comparison. The catch turned that into an empty snapshot, and comparing two empty snapshots renders "No change. 0 files" on every category. The check went green while measuring nothing, for a full run. It now runs wrangler's entry through process.execPath, naming no package manager at all, which this job needs anyway because it builds two commits that can disagree about which manager exists. The guard is the other half. A snapshot of nothing is never legitimate here, so record refuses to write one. It checks MODULES as well as files, because a failed dry-run in a tree still holding a .build from an earlier run counts 69 files off those stale bytes and reports 0 modules; measured both ways, and the file-count half alone would have missed it. Controls: broken wrangler path exits 1 and writes no file; the real path records 69 files, 85 modules. --------- Co-authored-by: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com>
…lker Step 7c writes the sha256 map the enforcing `script-src` is built from. It read HTML with a hand-rolled tag walker, correct only because it had been patched three times against the served bytes. That is the last hand-rolled scanner in the HTML passes, and this build has already caught three naive ones on its own minified output: a `<script` search that read /garage/horizon's XSS demo payload as two real scripts, a link scanner that found 33 refs where there were 2645, and the attribute scanner in this very step, which had to learn quotes for the same reason. minify-html unquotes attributes and decodes character references inside quoted values, so anything written against the AUTHORED HTML reads the MINIFIED HTML wrong. HTMLRewriter is lol-html, the SAME engine src/worker/ runs, so the build now reads a document the way the Worker does. lib/link-integrity.ts made this exact move on 2026-08-20; this finishes it. Salvaged from the parked bun branch (#443) and re-pointed at the current layout. BYTE-IDENTICAL, which is the bar rather than a nice result. csp-hashes.ts feeds the Worker bundle and /a/ URLs are content-addressed, so one moved byte re-mints assets and orphans dictionary snapshots. Building both scanners against the same tree: 110 inline blocks across 54 documents, 110 hashes either way, csp-hashes.ts identical, and 1570 of 1570 staged files identical. 19ms for the 54 documents. 107 lines of walker out, a 130-line module with its argument in the header in. The tools type ratchet drops 504 to 501, since three of build.ts's errors were the walker's untyped locals. WHAT THE TESTS PIN, each with a control that fails without it: - only executable scripts are hashed. Bare, module and speculationrules yes; application/json and ld+json no, because they are parsed as data and never reach script-src, and 31 of this site's inline blocks are data. Control: making every type executable fails it. - an empty <script></script> still hashes the empty string. It has no text node, which is why the capture closes on the END TAG. Control: hashing only blocks that produced text fails it. - a body is hashed as SOURCE bytes, so `&` is not decoded first. Control: decoding the chunk fails it. - a srcdoc document's scripts hash against the EMBEDDING page, identically to the same script in the parent. Control: dropping the recursion fails it. - document order, an unterminated script throwing, and the parser requirement naming HTMLRewriter rather than dying on an undefined symbol. The handler test carries the trap as an ASSERTION rather than a comment: the naive /\son\w+=/ finds three handlers in the fixture and the parser finds one, and the test fails if that stops being true, so the fixture cannot quietly stop testing anything. lint, typecheck, 519 tests, tools:check, pages:check, photos:check, perf-budget, 149 routes and a wrangler dry-run at 708.52 KiB / 239.88 KiB gzip.
|
Retiring this. Bun went GA and landed on Swept the remainder before closing. Three things were still worth taking, and they are in #657 and #658:
One finding did not survive, and that is worth recording here rather than only in the PR. Measured 2026-08-28 over all 165 stems, three binaries with three distinct sha256s off one Deliberately skipped: the shell-to-bun conversion of 12 scripts. Branch stays for reference. |
…lker (#658) * build(csp): the hash scan reads documents with a parser, not a tag walker Step 7c writes the sha256 map the enforcing `script-src` is built from. It read HTML with a hand-rolled tag walker, correct only because it had been patched three times against the served bytes. That is the last hand-rolled scanner in the HTML passes, and this build has already caught three naive ones on its own minified output: a `<script` search that read /garage/horizon's XSS demo payload as two real scripts, a link scanner that found 33 refs where there were 2645, and the attribute scanner in this very step, which had to learn quotes for the same reason. minify-html unquotes attributes and decodes character references inside quoted values, so anything written against the AUTHORED HTML reads the MINIFIED HTML wrong. HTMLRewriter is lol-html, the SAME engine src/worker/ runs, so the build now reads a document the way the Worker does. lib/link-integrity.ts made this exact move on 2026-08-20; this finishes it. Salvaged from the parked bun branch (#443) and re-pointed at the current layout. BYTE-IDENTICAL, which is the bar rather than a nice result. csp-hashes.ts feeds the Worker bundle and /a/ URLs are content-addressed, so one moved byte re-mints assets and orphans dictionary snapshots. Building both scanners against the same tree: 110 inline blocks across 54 documents, 110 hashes either way, csp-hashes.ts identical, and 1570 of 1570 staged files identical. 19ms for the 54 documents. 107 lines of walker out, a 130-line module with its argument in the header in. The tools type ratchet drops 504 to 501, since three of build.ts's errors were the walker's untyped locals. WHAT THE TESTS PIN, each with a control that fails without it: - only executable scripts are hashed. Bare, module and speculationrules yes; application/json and ld+json no, because they are parsed as data and never reach script-src, and 31 of this site's inline blocks are data. Control: making every type executable fails it. - an empty <script></script> still hashes the empty string. It has no text node, which is why the capture closes on the END TAG. Control: hashing only blocks that produced text fails it. - a body is hashed as SOURCE bytes, so `&` is not decoded first. Control: decoding the chunk fails it. - a srcdoc document's scripts hash against the EMBEDDING page, identically to the same script in the parent. Control: dropping the recursion fails it. - document order, an unterminated script throwing, and the parser requirement naming HTMLRewriter rather than dying on an undefined symbol. The handler test carries the trap as an ASSERTION rather than a comment: the naive /\son\w+=/ finds three handlers in the fixture and the parser finds one, and the test fails if that stops being true, so the fixture cannot quietly stop testing anything. lint, typecheck, 519 tests, tools:check, pages:check, photos:check, perf-budget, 149 routes and a wrangler dry-run at 708.52 KiB / 239.88 KiB gzip. * fix(csp): reserve the hash slot at the open tag, dropping onEndTag Attempt at the Workers Builds failure, which is the one red check on this PR and the only one nothing here can reproduce. WHAT IS RULED OUT. `validate` runs the same `bun run build` on a runner and passes. perf-diff builds BOTH the merge base and HEAD on linux-x64 and reports no change in any category, so it is not the platform. A pristine clone plus `bun install --frozen-lockfile` plus `bun tools/build.ts` exits 0. The artifact is byte-identical to the old walker's, 0 of 1570 staged files differing, so it cannot be what gets uploaded. Re-running the identical tree (amend, force-push, new build id) failed again, so it is not a flake. That leaves the build step inside Cloudflare's image, whose log needs the dashboard and whose bun version is recorded nowhere in this repo. `wrangler` has no builds subcommand. THE HYPOTHESIS, stated as one because it is not verified: el.onEndTag() was the newest HTMLRewriter API this module used, and the only part of the surface that lib/link-integrity.ts — the other parser caller, which builds fine in that image on main — does not exercise. So it goes. The replacement reserves a slot at the script's OPEN tag and lets the text handler fill it, which keeps document order exact without needing to know when the element closed. An empty <script></script> emits no text chunk at all, probed rather than assumed, so reserving at open is also what keeps it from being dropped. The text handler is scoped to `script` rather than `*`, and that is load-bearing rather than tidy: with a `*` handler an empty script is handed the next text the stream produces. Measured on `<script></script><p>following text</p>`, the body comes back as "following text", so the page would ship a hash for a script that does not exist while the real empty block stayed blocked. THAT ONE WAS UNTESTED UNTIL THE CONTROL SAID SO. Five mutations were run against the new implementation and four failed a test; scoping the handler to `*` failed nothing, because no fixture had an empty script followed by text. The comment was asserting something no test proved, which is the shape this repo keeps catching. There is a fixture now, and the control fails with it. ONE BEHAVIOUR CHANGES, and toward the browser. The old walker THREW on an unterminated <script>, because it went looking for a close tag and found none. That was a property of the walker: lol-html closes the text node at EOF with the body a browser would run, probed here, so hashing it is right and throwing would refuse to cover a block that really does execute. The test pins the new answer rather than dropping the assertion. Still byte-identical: 110 blocks across 54 documents, csp-hashes.ts unchanged, 0 of 1570 staged files differing. lint, typecheck, 520 tests, pages:check, tools:check, perf-budget, photos:check, 149 routes, dry-run 708.52 KiB / 239.88 KiB gzip. --------- Co-authored-by: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com>
Moves the toolchain to Bun 1.4 and reorganises the source tree around what each
file is. It cannot merge yet, and the reason is Cloudflare's build image rather
than this branch. See Blocked.
28 commits, each independently verified. The bar was byte-identical build output
for the toolchain work, and interoperability (routes resolve, suites pass) once
the layout itself started moving.
What changed
src/worker/src/client/+src/styles/src/pages/,src/content/,src/dict/,public/www/split by what a file istools/scripts/merged withphotos/andoxlint/; no shell left in the repoGreen throughout: 316 contract tests, 59 cal, 13 lens-reader, typecheck, lint,
check-wrangler,pages:check,tools:check,perf-budget, 136 routes, and awrangler dry-run. Worker bundle 229.15 KiB gzip. Build wall clock 2.1s.
The pin is a digest, not a version
Bun 1.4 is not released. It is on npm in no form, so it ships only as a GitHub
asset under the tag
canary, and that tag rolls: no immutable per-canary tagexists.
1.4.0-canary.1is a label that stays fixed while the binary under itchanges.
So
config/bun-canary.jsondeclares the SHA-256 for both platforms, and.github/actions/setup-bunfails by name when it moves. It also probes thecapability the version is actually for: bun 1.3.14 accepts
zstdCompressSync'sdictionaryand silently ignores it, which would ship no-op dcz deltas.Expect that to go red often. It rolled twice on 2026-08-17 alone, so anyone
arriving cold is likely to find
validatered on the setup step rather than onthe diff. Each bump re-runs the same controls, and the build has stayed
byte-identical across all 1499 files through every one of them.
Blocked, and the blocker is upstream
Waiting on one thing: bun 1.4 going GA. Not on this branch, and not on
review. Four routes to publishing this were measured on 2026-08-18; the record
and the day-one checklist for 1.4 are in
docs/BUN-MIGRATION.md.SKIP_DEPENDENCY_INSTALLplus a self-installed canaryBUN_VERSIONorpackageManagerThe image resolves a RELEASED bun version or nothing, and released bun cannot
parse a v2 lockfile, which is what stopped the first two builds outright:
That asymmetry is one-way and is what makes the branch deployable anyway: bun
1.3.14 writes and reads v1, while the canary reads BOTH. So
bun.lockis v1,packageManagernamesbun@1.3.14because that is the only string the imageresolves, and
config/bun-canary.jsonkeeps pinning the canary by digest for CIand the workstation. A contract test holds the lockfile at v1, since a bare
bun installunder the canary would silently upgrade it and the next symptom would bea failed production deploy.
perf-difffails for a mechanical reason and gates nothing: it builds the mergebase using this branch's workflow, so it runs
bun install --frozen-lockfileagainst a tree that has no
bun.lock. It resolves itself once this is onmain.CodeQL's 11 alerts are all re-detections. Every one maps to a finding alreadydismissed on
mainunder its pre-move path (alerts #1, #14, #26, #27, #48-50,#58, #59, #64, #69), which is what a 1229-file move does to alert identity. The
code at every flagged line is unchanged, and the load-bearing rationale was
re-checked rather than inherited:
lensMarkdownoutput still reaches the DOMonly through
pre()intoesc().What bun won that node cannot do at all
Bun ships HTMLRewriter, which is lol-html, the same parser
src/worker/runs.build.mjsstep 7c writes the sha256 map the enforcingscript-srcis builtfrom, and it read HTML with a hand-rolled tag walker that was correct only
because it had been patched three times against the served bytes.
It now reads documents with a parser, in 35ms for 48 pages, and reconciles
exactly: 50 untyped + 46
speculationrules+ 1moduleexecutable, plus 1inside
garage/horizon.html'siframe[srcdoc], is the 98 hashes this step hasalways reported, with 31
application/jsonandld+jsondata blocks correctlyleft alone. Byte-identical output. Five tests pin it, two controls prove they can
fail, and the build now has one runtime by design.
The photo pipeline runs 8 at a time
Measured over 24 real photos through the phase-1 chain: serial 836 ms/photo, 4 →
228, 6 → 184, 8 → 159, 10 → 163, 12 → 150. The knee is 8, because avifenc
already takes
--jobs 4. End to end on the heaviest loop, 30 files throughexport-for-instagram --dry-run: 33.7s to 6.2s with byte-identical stdout.Two controls, and the second found a real bug. Encoders first, since
/i/iscontent-addressed: 8 photos serial against pooled, all 32 encoded outputs
byte-identical, no encoder flag touched. Then stdout, which came back different,
because intermediates were keyed by STEM and two inputs can share one. That is a
harmless overwrite at one photo at a time and a race at eight. Intermediates are
per ITEM now.
What Bun did not win
bun run bun:build:checkmeasures whetherBun.build()should replace anytransform. It should not, on all three:
luna.css)Bun wins where it runs code and loses where it transforms bytes.
node is down to two holdouts
#!/usr/bin/env nodeshebangroutes:check, which boots workerd throughcreateTestHarness()and hangsunder bun
Both are wrangler/miniflare/workerd being node-pinned.
Also here
bun test --coverageneeds no configuration, sobun run test:coveragenameswhat the suite never executes (7707 of 14063 lines, 54.8%;
updates.tsat 4.7%).CI runs it under
continue-on-errorand puts the table in the job summary. Itgates NOTHING on purpose: a coverage floor is one more constant somebody widens.
Things this turned up
check-wranglerhad been agreeing with itself: it counted transitivewrangler copies by reading
node_modules/.pnpm, which stopped existing, andthe
try/catchreported "0 copies" on every run.tripwire was
mirrored < 25against a real count of 33./*min*/CSS pass matched nothing after the TS rename and had nofloor, so it printed "0 literals" and shipped unminified CSS on every
Worker-rendered page.
gzipis runtime-dependent, brotli is not. Bun'snode:zlibgzip runs~0.8% larger; brotli q11 is byte-identical.
into a public URL change.
icons.svgnamed its generator by path; now by name.and the bake is
--lockednow, with one deliberate rebake to close the gap.Full record:
docs/BUN-MIGRATION.md.