Adds a `static_v(request, name, path=...)` Jinja helper that appends
?v=<commit-sha> from app.core.build_info, plus a CachedStaticFiles
subclass that serves Cache-Control: public, max-age=31536000, immutable
in production and no-cache in development. Browsers refetch JS/CSS
automatically on every deploy without the user having to hard-reload.
- New: app/core/static_files.py (CachedStaticFiles)
- Updated: app/templates_config.py (static_v helper)
- Updated: main.py (use CachedStaticFiles for *_static mounts)
- Codemod: 143 url_for('*_static', path='*.js'|'*.css') → static_v(...)
across 123 templates. Images/fonts/JSON locales intentionally
unchanged (out of scope).
- Arch rule: FE-024 (warning) flags raw url_for on JS/CSS to prevent
drift. Note: FE-008 was already taken by the number_stepper rule.
- docs/proposals/static-asset-cache-busting.md marked Done.
Closes plan from docs/proposals/static-asset-cache-busting.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5.0 KiB
Static Asset Cache-Busting Plan
Date: 2026-05-17 Implemented: 2026-05-18 Status: Done Motivation: Users must hard-reload after every release to pick up new JS/CSS. We need versioned asset URLs that flip automatically on deploy so browsers refetch without manual intervention.
Implementation summary
static_v(request, name, *, path)Jinja global inapp/templates_config.py— appends?v=<commit-sha>frombuild_info.CachedStaticFilesinapp/core/static_files.py— subclass offastapi.staticfiles.StaticFilesthat setsCache-Control: public, max-age=31536000, immutablein production andno-cachein development. Wired into the JS/CSS-bearing mounts inmain.py(module statics +/static). Uploads and module locales keep default headers.- Codemod migrated 143
url_for('*_static', path='*.js'|'*.css')callsites across 123 templates tostatic_v(...). - Architecture rule
FE-024(warning) flags rawurl_for(...)on JS/CSS so the pattern can't drift back. (Renamed from the proposedFE-008, which was already taken by thenumber_stepperrule.) - Verified:
mkdocs build --strictclean,validate-architecturereports no new findings, smoke test renders?v=<sha>.
Existing Infrastructure (Already in Place)
app/core/build_info.pyreads.build-info(written byscripts/deploy.sh) for the deploy commit SHA, falling back togit rev-parse --short=8 HEADfor local dev.app/templates_config.py:102-105exposesconfig.commitandconfig.deployed_atas Jinja globals.- All static refs use the
url_for('<module>_static', path='...')pattern — 143 occurrences across templates.
The version source is solved. The remaining work is: surface it on every static URL, and let browsers cache aggressively.
Phase 1 — Versioned URL helper (~30 min)
Add one Jinja global in app/templates_config.py:
def _asset_version() -> str:
info = get_build_info()
return info.get("commit") or info.get("deployed_at", "dev")[:14]
def static_v(request, name: str, *, path: str) -> str:
return f"{request.url_for(name, path=path)}?v={_asset_version()}"
templates.env.globals["static_v"] = static_v
Templates change from:
<script src="{{ url_for('dev_tools_static', path='admin/js/sql-query.js') }}"></script>
to:
<script src="{{ static_v(request, 'dev_tools_static', path='admin/js/sql-query.js') }}"></script>
request is already in scope in every Jinja2Templates render. No new params to thread.
Phase 2 — Migrate the 143 callsites (~1 hr)
Codemod sweep. Decision point: bust everything, or only JS/CSS?
Recommendation: JS/CSS only. Images/fonts/icons rarely change in ways that matter mid-session, and the migration is smaller. Keep raw url_for for those.
Regex pattern (scoped to .js and .css):
url_for\(('[a-z_]+_static'), path=('[^']+\.(?:js|css))'\)
→ static_v(request, \1, path=\2)
Add an architecture rule FE-008 that warns on raw url_for('*_static', path='*.js'|'*.css') to prevent drift. Severity: warning initially, error once we've cleared the codebase.
Phase 3 — Server-side aggressive caching (~15 min)
With the version in the query string, static responses can cache forever:
StaticFiles(directory=..., html=False)
# Wrap with middleware adding:
# Cache-Control: public, max-age=31536000, immutable
# for /static/... paths
This is the payoff. Without it, the version query string only forces a conditional GET; with it, the browser doesn't even hit the server until the version flips.
Phase 4 — Verification
- Manual: hard-reload → soft-reload → confirm 304 or memory-cache.
- Smoke test: render any template, assert script tags contain
?v=. - Sanity:
scripts/deploy.shwrites.build-infoon every deploy (already does — confirm in CI).
Open Decisions
- Bust everything, or just JS/CSS? → Recommend JS/CSS only.
- Version source: commit SHA or deploy timestamp? → SHA primary, timestamp fallback (already implemented by
build_info.py). - All 143 callsites in one PR, or incremental? → All at once via codemod. Cleaner than maintaining two styles.
FE-008severity at introduction? → Warning, escalate to error after migration.
Files Touched
app/templates_config.py— addstatic_vhelper.app/templates/**/*.htmlandapp/modules/*/templates/**/*.html— codemod (JS/CSS refs only).app/main.py(or whereverStaticFilesis mounted) — add cache headers middleware..architecture-rules/frontend.yaml(or equivalent) —FE-008rule.docs/development/troubleshooting.md— note the new?v=query string in case it confuses anyone debugging static loading.
Out of Scope
- Per-file hashed filenames (Webpack-style). Not worth the build complexity given Jinja-rendered templates.
- CDN integration. Same
?v=strategy works behind a CDN; revisit if/when one is introduced. - Service worker caching. None today; revisit if PWA support is added.