diff --git a/src/ci_orchestrator/web/app.py b/src/ci_orchestrator/web/app.py index 8d58266..8296c4c 100644 --- a/src/ci_orchestrator/web/app.py +++ b/src/ci_orchestrator/web/app.py @@ -571,6 +571,30 @@ def services_partial(request: Request) -> HTMLResponse: # ─────────────────────────────────────────────────────────────────── factory +def _static_asset_version(web_dir: Path) -> str: + """Cache-busting query value shared by ``/static/app.css`` and + ``/static/app.js`` (base.html stamps both tags with the same call). + + StaticFiles sends only ETag/Last-Modified, no Cache-Control, so a plain + browser reload can serve a stale asset after a redeploy; the query + string exists to force a brand new URL instead. It has to move on the + *newer* of the two files' mtimes, not just app.css's -- a JS-only deploy + otherwise leaves app.css's mtime (and so the shared version, and so the + app.js URL) unchanged, and the browser keeps whatever app.js it already + cached under that now-stale-content, unchanged URL. + """ + static_dir = web_dir / "static" + mtimes: list[float] = [] + for name in ("app.css", "app.js"): + try: + mtimes.append((static_dir / name).stat().st_mtime) + except OSError: + continue + if not mtimes: + return "0" + return str(int(max(mtimes))) + + def create_app(config: Config) -> FastAPI: """Build the dashboard app: W4 read-only routes + W5 auth + W6 mutating actions. @@ -607,20 +631,7 @@ def create_app(config: Config) -> FastAPI: # remember to thread it through. app.state.templates.env.globals["gitea_url"] = gitea_base_url(config.web) - # Cache-busting for the stylesheet: StaticFiles sends only ETag/ - # Last-Modified (no Cache-Control), so a plain browser reload can serve a - # stale app.css after a redeploy. Stamping the with the file's - # mtime -- re-read per render -- means every deploy changes the URL, so a - # normal navigation refetches the CSS with no hard-refresh needed. - static_css = _WEB_DIR / "static" / "app.css" - - def _asset_version() -> str: - try: - return str(int(static_css.stat().st_mtime)) - except OSError: - return "0" - - app.state.templates.env.globals["asset_version"] = _asset_version + app.state.templates.env.globals["asset_version"] = lambda: _static_asset_version(_WEB_DIR) app.mount("/static", StaticFiles(directory=str(_WEB_DIR / "static")), name="static") app.get("/healthz")(healthz) diff --git a/tests/python/test_web_app.py b/tests/python/test_web_app.py index 7766970..7f2910e 100644 --- a/tests/python/test_web_app.py +++ b/tests/python/test_web_app.py @@ -8,6 +8,7 @@ real host state (disk, systemd) leaks into the assertions either. from __future__ import annotations +import os import shutil from collections import namedtuple from pathlib import Path @@ -337,3 +338,31 @@ def test_dashboard_lists_a_running_vm_under_its_run( assert resp.status_code == 200 assert "job1" in resp.text + + +# ── _static_asset_version ──────────────────────────────────────────────── + + +def test_asset_version_moves_when_only_js_changes(tmp_path: Path) -> None: + """A JS-only deploy must still change the shared cache-busting query -- + otherwise the browser keeps serving whatever app.js it cached under the + unchanged URL (app.css's mtime alone used to drive this value).""" + static_dir = tmp_path / "static" + static_dir.mkdir() + css = static_dir / "app.css" + js = static_dir / "app.js" + css.write_text("body{}") + js.write_text("//v1") + os.utime(css, (1_000_000, 1_000_000)) + os.utime(js, (1_000_000, 1_000_000)) + + before = app_module._static_asset_version(tmp_path) + + os.utime(js, (2_000_000, 2_000_000)) # touch only the JS file + after = app_module._static_asset_version(tmp_path) + + assert after != before + + +def test_asset_version_degrades_to_zero_when_both_assets_are_missing(tmp_path: Path) -> None: + assert app_module._static_asset_version(tmp_path) == "0"