← All decisions

Content-hash glemy-website's built static assets: style.css per-file, the play-demo build as one renamed directory

architecturedeploymentcachingwebsite

Context

docs/technical-architecture.md §3.3 (decision 0042) required built static assets to be published under content-derived filenames, to close the risk of a visitor's browser serving a stale cached asset against a freshly deployed page during the several-second window a deploy is in flight -- tracked as roadmap item RM-024. The site has two kinds of build output to consider: a single committed file (style.css) and a whole directory glemy-website doesn't author (glemy's own JS build, copied in wholesale at build time), which needed different treatment.

Options considered

Hash every file inside the play-demo build individually, matching typical bundler output (main.abc123.js) — rejected
This is a bundler-free, raw-ESM build (decision 0003): the files import each other by relative path, so renaming any one of them would break every other file's import of it without also rewriting those import statements -- exactly the kind of bundler-shaped tooling this project has deliberately avoided. Content-hashing the whole directory as one unit instead achieves the same cache-busting property (any change to any file changes the directory's hash, so the whole thing gets a new URL) without touching a single file's contents or import paths.
Use a hand-rolled Erlang :crypto FFI call for hashing — rejected
gleam_crypto is a real, official gleam-lang package (dual-target, Erlang and JavaScript), already covering exactly this need (hash(Sha256, data)) with a typed API. Using it over raw FFI matches this project's own established preference for a real typed library over hand-rolled FFI wherever one genuinely fits, and costs nothing extra since build.gleam is Erlang-only anyway.
Compute the play-demo directory's hash in the CI workflow's shell steps (e.g. find | sha256sum) rather than in Gleam — rejected
Would require the hash logic to exist in two places (a shell one-liner in deploy.yml, a Gleam one for style.css) and would need a new mechanism (an env var, a file) to pass the computed hash from the shell step into build.gleam. Computing it in Gleam instead means build.gleam can discover and use whatever's actually on disk with no new coupling between the workflow and the build script -- the workflow's existing copy step needed zero changes.

Decision

Added glemy_website/asset_hash.gleam: a pure, file-I/O-free module (hash(BitArray) -> 8-hex-character SHA-256 prefix via gleam_crypto; hashed_filename(name, hash) -> name with the hash inserted before the extension) -- deliberately separated from all the actual file reading so it stays fully unit-testable without touching the filesystem. style.css's source moved from static/ (copied verbatim by add_static_dir under its original name) to a new assets/ directory; build.gleam reads it, computes its hash, and publishes it via add_static_asset under the hashed name, with layout.gleam's new stylesheet_url building the one canonical reference every page uses. glemy's play-demo build, copied into static/play-demo/ by the existing CI/local-dev copy step (unchanged), is hashed as a whole directory by build.gleam (every file's bytes concatenated, then hashed) and renamed in place to static/play-demo-<hash>/ before lustre_ssg's add_static_dir runs; game_card.gleam's play button now links to that discovered path. Tolerant of re-running locally without recopying the demo: if the unhashed directory is gone, it looks for whatever play-demo-* directory already exists instead of failing.

Verification

New unit tests (glemy_website/asset_hash_test.gleam) cover determinism, distinctness for different content, output shape, filename insertion including a multi-dot and a no-extension case, and pin the algorithm/encoding/truncation against a real, independently verifiable external reference (the well-known SHA-256 digest of "hello") -- 17/17 tests pass. A full local build with real data produced consistent, correctly cross-referenced hashes on every page (style.4774e509.css referenced identically on /, /devlog, /roadmap, /play; the game card linking to /play-demo-3948ec22/index.html). Re-running the build a second time without recopying the demo reused the same already-hashed directory and produced an identical hash, confirming idempotency. A real headless-browser pass against the renamed directory directly showed 150 real WebGPU frames rendering and zero errors beyond the same pre-existing, already-diagnosed glemy favicon 404 (confirmed unrelated) -- proving the internal relative .mjs imports inside the renamed directory still resolve correctly, the actual risk this design was built to avoid.

Consequences

Closes RM-024. Every deployed page now references assets by a filename that changes whenever the asset's actual content does, closing the stale-cache-during-deploy risk technical-architecture.md §3.3 identified. static/play-demo-<hash>/ directories can accumulate harmlessly across repeated local builds with changing content (each gitignored, cleaned by deleting static/ if it matters) -- not cleaned up automatically, a deliberate simplicity trade-off rather than added directory-management logic for a purely local, low-stakes cosmetic issue. The README's description of the live demo as iframe-embedded was also found stale (from the earlier game-card redesign) and corrected while touching this area.

References