Skip to content

build: move the toolchain to Bun 1.4, and split the tree by what a file is - #443

Closed
oddharsh wants to merge 32 commits into
mainfrom
bun/greenfield
Closed

oddharsh wants to merge 32 commits into
mainfrom
bun/greenfield

Conversation

@oddharsh

@oddharsh oddharsh commented Aug 17, 2026 •

Copy link
Copy Markdown
Owner

PARKED, deliberately. The toolchain stays pnpm + node until bun 1.4 goes
GA, at which point this branch is the jump-straight-to-1.4 path rather than a
migration to redo. Nothing here is waiting on review or on a fix; it is waiting
on one upstream release. docs/BUN-MIGRATION.md carries the measured record and
a day-one checklist.

Expect validate to be red most days. config/bun-canary.json pins the bun
binary by SHA-256 and the canary tag rolls; it rolled three times on
2026-08-18 alone. A red setup step is that pin working, not a defect in the
diff. Re-verify with bun run bun:canary:check before believing any of the
numbers below.

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

toolchain Bun installs, runs every script, runs the tests, runs the build
src/worker/ the Worker, now TypeScript, out of the served tree
src/client/ + src/styles/ the islands and stylesheets
src/pages/, src/content/, src/dict/, public/ www/ split by what a file is
tools/ scripts/ merged with photos/ and oxlint/; no shell left in the repo
build the CSP hash scan reads documents with HTMLRewriter
photo pipeline the per-photo chains run 8 at a time

Green throughout: 316 contract tests, 59 cal, 13 lens-reader, typecheck, lint,
check-wrangler, pages:check, tools:check, perf-budget, 136 routes
, and a
wrangler 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 tag
exists. 1.4.0-canary.1 is a label that stays fixed while the binary under it
changes.

So config/bun-canary.json declares the SHA-256 for both platforms, and
.github/actions/setup-bun fails by name when it moves. It also probes the
capability the version is actually for: bun 1.3.14 accepts zstdCompressSync's
dictionary and 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 validate red on the setup step rather than on
the 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.

route verdict
released bun in the image, deltas delegated to node 26 works, committed
SKIP_DEPENDENCY_INSTALL plus a self-installed canary declined: puts a 35MB compiler fetch on the publish path, and a rolled tag fails it closed
a canary via BUN_VERSION or packageManager impossible, three builds
a canary from npm does not exist; npm's newest bun is 1.3.14

The 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:

bun install v1.3.14
  "lockfileVersion": 2,
error: Unknown lockfile version   at bun.lock:2:22

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.lock is v1,
packageManager names bun@1.3.14 because that is the only string the image
resolves, and config/bun-canary.json keeps pinning the canary by digest for CI
and the workstation. A contract test holds the lockfile at v1, since a bare bun install under the canary would silently upgrade it and the next symptom would be
a failed production deploy.

perf-diff fails for a mechanical reason and gates nothing: it builds the merge
base using this branch's workflow, so it runs bun install --frozen-lockfile
against a tree that has no bun.lock. It resolves itself once this is on main.

CodeQL's 11 alerts are all re-detections. Every one maps to a finding already
dismissed on main under 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: lensMarkdown output still reaches the DOM
only through pre() into esc().

What bun won that node cannot do at all

Bun ships HTMLRewriter, which is lol-html, the same parser src/worker/ runs.
build.mjs step 7c writes the sha256 map the enforcing script-src is built
from, 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 + 1 module executable, plus 1
inside garage/horizon.html's iframe[srcdoc], is the 98 hashes this step has
always reported, with 31 application/json and ld+json data blocks correctly
left 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 through
export-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/ is
content-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:check measures whether Bun.build() should replace any
transform. It should not, on all three:

current bun
CSS (luna.css) 7,746 br 11,356 br (+46.6%)
JS (15 islands) 60,706 br 62,133 br (+2.4%)
HTML entrypoints bespoke pipeline fails to build

Bun wins where it runs code and loses where it transforms bytes.

node is down to two holdouts

  1. the wrangler CLI, via its #!/usr/bin/env node shebang
  2. routes:check, which boots workerd through createTestHarness() and hangs
    under bun

Both are wrangler/miniflare/workerd being node-pinned.

Also here

bun test --coverage needs no configuration, so bun run test:coverage names
what the suite never executes (7707 of 14063 lines, 54.8%; updates.ts at 4.7%).
CI runs it 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.

Things this turned up

  • check-wrangler had been agreeing with itself: it counted transitive
    wrangler copies by reading node_modules/.pnpm, which stopped existing, and
    the try/catch reported "0 copies" on every run.
  • The client-edge CSS mirror silently shipped two pages short, because its
    tripwire was mirrored < 25 against a real count of 33.
  • The /*min*/ CSS pass matched nothing after the TS rename and had no
    floor, so it printed "0 literals" and shipped unminified CSS on every
    Worker-rendered page.
  • gzip is runtime-dependent, brotli is not. Bun's node:zlib gzip runs
    ~0.8% larger; brotli q11 is byte-identical.
  • A repository path inside a content-addressed artifact turns a file move
    into a public URL change. icons.svg named its generator by path; now by name.
  • The committed histograms came from an unrecorded toolchain. rustc is pinned
    and the bake is --locked now, with one deliberate rebake to close the gap.

Full record: docs/BUN-MIGRATION.md.

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.
…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.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 17, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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 `&amp;` 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="&lt;img onerror=…&gt;"` 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.
@oddharsh
oddharsh marked this pull request as ready for review August 17, 2026 22:07
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.
@oddharsh
oddharsh marked this pull request as draft August 18, 2026 03:29
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.
oddharsh added a commit that referenced this pull request Aug 18, 2026
`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.
oddharsh added a commit that referenced this pull request Aug 18, 2026
…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>
oddharsh added a commit that referenced this pull request Aug 18, 2026
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.
oddharsh added a commit that referenced this pull request Aug 18, 2026
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.
oddharsh added a commit that referenced this pull request Aug 20, 2026
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>
oddharsh added a commit that referenced this pull request Aug 20, 2026
* 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>
oddharsh added a commit that referenced this pull request Aug 28, 2026
…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 `&amp;` 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.
@oddharsh

Copy link
Copy Markdown
Owner Author

Retiring this. Bun went GA and landed on main on 2026-08-20, the www/ split landed on 2026-08-18, and the photo-pipeline concurrency landed separately as run_parallel in bash, so most of this branch is already shipped by other routes. The canary digest pinning is superseded by bun run bun:pin, and bun-image-probe has answered its questions.

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. a628f5d1 proposed pinning rustc on a measured 31.2% of 40,448 histogram bins moving. It never ran an A/B between two compilers: it compared a fresh bake against the committed set, ruled out Cargo.lock, the input hashes and nondeterminism, and took rustc as the last suspect standing. Gotcha 41 found the real cause a week later, when #394's re-encode turned out to have changed the input JPEGs.

Measured 2026-08-28 over all 165 stems, three binaries with three distinct sha256s off one --locked lockfile: rustc 1.93.0, 1.96.0 and 1.98.0 produce byte-identical histograms, 0 files differing on either pair, with a control proving the diff moves exactly one stem when one input tile is re-encoded. No pin was added. That comparison is committed as bun run zenc:reproducible so the question does not get re-derived a third time.

Deliberately skipped: the shell-to-bun conversion of 12 scripts. main has since hardened those with set -euo pipefail plus a contract test enforcing it, and the concurrency win arrived by another road.

Branch stays for reference.

@oddharsh oddharsh closed this Aug 28, 2026
oddharsh added a commit that referenced this pull request Aug 28, 2026
…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 `&amp;` 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>
@oddharsh
oddharsh deleted the bun/greenfield branch September 15, 2026 02:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant