From cbbc01861c8491be2bf63de399023f66cb348afe Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 9 Aug 2026 17:59:31 -0700 Subject: [PATCH] Version app assets to prevent deploy-skew rendering A deploy can leave a browser holding cached app.js against fresh index.html (or stale data against fresh code), which rendered the national table visually off by a column during the FY 2025 lens rollout. Assets now carry a shared cache-buster: index.html loads app.js?v=, app.js declares the same stamp as ASSET_V and appends it to every data fetch, and a test locks the two in step so a deploy that bumps one without the other fails CI. Co-Authored-By: Claude Fable 5 --- app/public/app.js | 7 ++++--- app/public/index.html | 2 +- tests/test_asset_versioning.py | 23 +++++++++++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 tests/test_asset_versioning.py diff --git a/app/public/app.js b/app/public/app.js index 41995bc..6343224 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -7,6 +7,7 @@ const TIERS = [[6, 0], [8, 5], [10, 10], [Infinity, 15]]; const TIER_LABELS = { 0: "0% share", 5: "5% share", 10: "10% share", 15: "15% share" }; const TIER_VARS = { 0: "--tier-0", 5: "--tier-5", 10: "--tier-10", 15: "--tier-15" }; const DRAWS = 4000; +const ASSET_V = "20260809a"; // bump with index.html's app.js?v= on every deploy that changes any asset const SCEN_SCHEMA = "snap_qc_sim.model_scenarios.v1"; const fmtM = (v) => { @@ -329,8 +330,8 @@ async function loadModelArtifacts() { if (!ARTIFACTS) { ARTIFACTS = (async () => { const [mdRes, scRes] = await Promise.all([ - fetch("model_data.json"), - fetch("model_scenarios.json"), + fetch("model_data.json?v=" + ASSET_V), + fetch("model_scenarios.json?v=" + ASSET_V), ]); if (!mdRes.ok || !scRes.ok) throw new Error("model artifact fetch failed"); const mdBytes = await mdRes.arrayBuffer(); @@ -620,7 +621,7 @@ function render() { } async function main() { - DATA = await (await fetch("data.json")).json(); + DATA = await (await fetch("data.json?v=" + ASSET_V)).json(); const sel = $("state"); for (const [code, st] of Object.entries(DATA.states)) { const o = document.createElement("option"); diff --git a/app/public/index.html b/app/public/index.html index 0eca0ee..b060747 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -132,6 +132,6 @@

All states at a glance

Built by PolicyEngine · working paper · estimates are illustrative, from public data · hello@policyengine.org - + diff --git a/tests/test_asset_versioning.py b/tests/test_asset_versioning.py new file mode 100644 index 0000000..37a0263 --- /dev/null +++ b/tests/test_asset_versioning.py @@ -0,0 +1,23 @@ +"""The deploy cache-buster: index.html's script version must match app.js's.""" + +import re +from pathlib import Path + +PUBLIC = Path(__file__).resolve().parent.parent / "app" / "public" + + +def test_index_script_version_matches_app_constant(): + html = (PUBLIC / "index.html").read_text() + js = (PUBLIC / "app.js").read_text() + html_v = re.search(r'src="app\.js\?v=([A-Za-z0-9._-]+)"', html) + js_v = re.search(r'const ASSET_V = "([A-Za-z0-9._-]+)"', js) + assert html_v, "index.html must load app.js with a ?v= cache-buster" + assert js_v, "app.js must define ASSET_V" + assert html_v.group(1) == js_v.group(1) + + +def test_all_data_fetches_are_versioned(): + js = (PUBLIC / "app.js").read_text() + for name in ("data.json", "model_data.json", "model_scenarios.json"): + assert f'fetch("{name}?v=" + ASSET_V)' in js, f"{name} fetch must carry ASSET_V" + assert f'fetch("{name}")' not in js, f"unversioned {name} fetch remains"