From 446579e96c4486e39a3d0bbd4f0bd473925c19f5 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 9 Aug 2026 14:10:54 -0400 Subject: [PATCH] =?UTF-8?q?Asset=20stamps=20become=20content=20hashes=20?= =?UTF-8?q?=E2=80=94=20version=20stamps=20bust=20nothing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eric's screenshot showed week-old CSS again, straight through the cache-buster: the stamp was the app VERSION, unchanged at 1.0.0 since release, so every stylesheet change shipped at the same ?v= URL and browsers rightly kept their copies. The stamp is now an 8-char hash over the static bundle's bytes — it changes exactly when the files do, releases or not. The test now pins that property instead of the version equality it used to celebrate. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g --- src/bggpipe/webreview.py | 24 +++++++++++++++++++----- tests/test_web_dashboard.py | 11 ++++++++--- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py index c926a69..4bc64bf 100644 --- a/src/bggpipe/webreview.py +++ b/src/bggpipe/webreview.py @@ -338,6 +338,19 @@ def _default_stages(cfg: Config) -> dict[str, Callable[..., object]]: # (href, page-name, label, badge-name) — badge names match app.js # ordered as the real workflow runs: photograph, proofread the reads # (titles), resolve + decide (review), then what ships (queue, library) +def _asset_stamp() -> str: + """Eight hex chars over the static bundle's bytes. Computed once per + request path is fine — the files cannot change under a running + server, and the read is microseconds.""" + import hashlib + + static = resources.files("bggpipe") / "static" + digest = hashlib.md5() + for name in ("app.css", "app.js"): + digest.update((static / name).read_bytes()) + return digest.hexdigest()[:8] + + NAV_PAGES = ( ("/", "pipeline", "Pipeline", ""), ("/photos", "photos", "Photos", "photos"), @@ -779,12 +792,13 @@ def create_app( (a detail page keeps its section lit).""" active = active or name templates = resources.files("bggpipe") / "templates" - # version-stamped asset URLs: a browser that cached last week's - # stylesheet must fetch the new one the moment the app upgrades - from bggpipe import __version__ - + # content-hashed asset URLs: the stamp changes whenever the FILES + # change — a version-number stamp proved worthless between + # releases, serving week-old CSS at an unchanged ?v=1.0.0 shell = ( - (templates / "shell.html").read_text().replace("__ASSET_V__", __version__) + (templates / "shell.html") + .read_text() + .replace("__ASSET_V__", _asset_stamp()) ) fragment = (templates / "pages" / f"{name}.html").read_text() nav = "\n".join( diff --git a/tests/test_web_dashboard.py b/tests/test_web_dashboard.py index 719a58b..023bd6b 100644 --- a/tests/test_web_dashboard.py +++ b/tests/test_web_dashboard.py @@ -689,9 +689,14 @@ def test_pipeline_counts_pending_work_not_queue_rows(tmp_path): def test_assets_are_version_stamped(tmp_path): """A cached stylesheet from last week must not survive an upgrade: asset URLs carry the app version.""" - from bggpipe import __version__ + import re html = _app(_cfg(tmp_path)).get("/").text - assert f"/static/app.css?v={__version__}" in html - assert f"/static/app.js?v={__version__}" in html + (stamp,) = set(re.findall(r'/static/app\.css\?v=([0-9a-f]{8})"', html)) + assert f"/static/app.js?v={stamp}" in html assert "__ASSET_V__" not in html + # the stamp is a CONTENT hash: it must change when the css changes, + # not when the release version does — that was the original sin + from bggpipe import webreview + + assert stamp == webreview._asset_stamp()