fix: serve a fresh JavaScript after a deployment (cache policy + asset version marker) #173

Merged
Corentin merged 1 commit from fix/asset-cache-busting into dev 2026-09-27 11:01:36 +02:00
Owner

Le defaut

Apres une mise en production, un navigateur pouvait continuer a executer l'ANCIEN JavaScript.

Mesure sur la production, /assets/js/product-recipe.js : aucun en-tete Cache-Control, seulement ETag et Last-Modified. Sans Cache-Control, un navigateur applique une fraicheur heuristique (RFC 9111 4.2.2, couramment un dixieme de l'age du document) et reutilise le fichier plusieurs heures sans revalider. ETag ne sert que si le navigateur decide de revalider -- ce que la fraicheur heuristique lui evitait justement. Apres la refonte du back-office, l'ancien JS tournait donc contre le nouveau HTML : les boutons ne faisaient rien, sans erreur visible.

La correction, en deux morceaux

1. Une politique de cache explicite -- docker/apache/cache.conf, ecrite une fois, incluse par les deux vhosts (borne et back-office).

Cible Politique
Adresse avec marqueur ?v=... public, max-age=31536000, immutable
Code sans marqueur (CSS/JS) no-cache (revalidation, 304 si inchange)
Media / polices sans marqueur public, max-age=604800 (inchange)
Document HTML no-cache
Document PHP (back-office, API) no-store, private

2. Un marqueur de version dans l'adresse -- App\Core\Asset, une seule fonction d'aide, injectee comme $asset() dans toute vue par App\Core\Controller. Aucun suffixe recopie a la main : une garde de test refuse toute adresse /assets/... ecrite en dur dans un attribut de vue.

Le marqueur est le SHA court deploye, lu dans src/VERSION (ecrit par scripts/deploy.sh, deja expose par GET /api/health). Repli sur la date de modification du fichier quand src/VERSION est absent (installation locale, pile de test -- il est ignore par git), puis aucun marqueur si rien n'est mesurable.

Mesures, avant et apres (pile jetable)

Requete Avant Apres
admin /assets/js/product-recipe.js (aucun Cache-Control) no-cache
admin /assets/js/product-recipe.js?v=... (aucun Cache-Control) public, max-age=31536000, immutable
admin /assets/css/admin.css (aucun) no-cache
admin /login no-store, no-cache, must-revalidate no-store, private
borne /products.html (aucun) no-cache
borne /assets/css/style.css (aucun) no-cache
borne /assets/images/.../burgers.png public, max-age=604800 public, max-age=604800

Adresse rendue dans /login, bout en bout :

sans src/VERSION    -> /assets/css/admin.css?v=1790497981  ->  immutable
src/VERSION=abc1234 -> /assets/css/admin.css?v=abc1234     ->  immutable   (/api/health : "abc1234")
src/VERSION=def5678 -> /assets/css/admin.css?v=def5678     ->  immutable   (/api/health : "def5678")
src/VERSION vide    -> /assets/css/admin.css?v=1790497981  ->  immutable   (repli, adresse valide)

Tests (ecrits avant le code)

tests/Unit/Core/AssetTest.php, 14 cas : presence du marqueur, changement de version, stabilite, src/VERSION absent / vide / corrompu, adresse deja porteuse d'une chaine de requete, chemin remontant, jamais de marqueur vide, plus la garde d'usage sur les vues.

Suite complete : PHPUnit 1698 tests verts, PHPStan niveau 6 sans erreur, 356 tests JS verts.

Ce que ce lot ne couvre pas

  • La borne reste non versionnee. src/public/borne/*.html est du HTML statique servi tel quel : sans etape de construction, ses adresses ne peuvent pas porter de marqueur. Elle retombe sur no-cache -- correct (le defaut est corrige), juste moins econome en allers-retours.
  • Un git pull a la main qui contourne scripts/deploy.sh laisserait src/VERSION inchange : le marqueur ne bougerait pas alors que le code a change. Le chemin documente (deploy.sh, aussi declenche par le CD) reecrit toujours src/VERSION.
  • La CSP n'est pas touchee (script-src 'self') : le marqueur est une chaine de requete, aucun script en ligne n'a ete introduit.
  • Aucune empreinte de contenu dans le NOM de fichier (le marqueur reste une chaine de requete) et aucun cache serveur/CDN n'est introduit.
## Le defaut Apres une mise en production, un navigateur pouvait continuer a executer l'ANCIEN JavaScript. Mesure sur la production, `/assets/js/product-recipe.js` : **aucun en-tete `Cache-Control`**, seulement `ETag` et `Last-Modified`. Sans `Cache-Control`, un navigateur applique une fraicheur heuristique (RFC 9111 4.2.2, couramment un dixieme de l'age du document) et reutilise le fichier plusieurs heures **sans revalider**. `ETag` ne sert que si le navigateur decide de revalider -- ce que la fraicheur heuristique lui evitait justement. Apres la refonte du back-office, l'ancien JS tournait donc contre le nouveau HTML : les boutons ne faisaient rien, sans erreur visible. ## La correction, en deux morceaux **1. Une politique de cache explicite** -- `docker/apache/cache.conf`, ecrite une fois, incluse par les deux vhosts (borne et back-office). | Cible | Politique | |---|---| | Adresse avec marqueur `?v=...` | `public, max-age=31536000, immutable` | | Code sans marqueur (CSS/JS) | `no-cache` (revalidation, 304 si inchange) | | Media / polices sans marqueur | `public, max-age=604800` (inchange) | | Document HTML | `no-cache` | | Document PHP (back-office, API) | `no-store, private` | **2. Un marqueur de version dans l'adresse** -- `App\Core\Asset`, une seule fonction d'aide, injectee comme `$asset()` dans toute vue par `App\Core\Controller`. Aucun suffixe recopie a la main : une garde de test refuse toute adresse `/assets/...` ecrite en dur dans un attribut de vue. Le marqueur est le SHA court deploye, lu dans `src/VERSION` (ecrit par `scripts/deploy.sh`, deja expose par `GET /api/health`). Repli sur la date de modification du fichier quand `src/VERSION` est absent (installation locale, pile de test -- il est ignore par git), puis aucun marqueur si rien n'est mesurable. ## Mesures, avant et apres (pile jetable) | Requete | Avant | Apres | |---|---|---| | `admin /assets/js/product-recipe.js` | *(aucun Cache-Control)* | `no-cache` | | `admin /assets/js/product-recipe.js?v=...` | *(aucun Cache-Control)* | `public, max-age=31536000, immutable` | | `admin /assets/css/admin.css` | *(aucun)* | `no-cache` | | `admin /login` | `no-store, no-cache, must-revalidate` | `no-store, private` | | `borne /products.html` | *(aucun)* | `no-cache` | | `borne /assets/css/style.css` | *(aucun)* | `no-cache` | | `borne /assets/images/.../burgers.png` | `public, max-age=604800` | `public, max-age=604800` | Adresse rendue dans `/login`, bout en bout : ``` sans src/VERSION -> /assets/css/admin.css?v=1790497981 -> immutable src/VERSION=abc1234 -> /assets/css/admin.css?v=abc1234 -> immutable (/api/health : "abc1234") src/VERSION=def5678 -> /assets/css/admin.css?v=def5678 -> immutable (/api/health : "def5678") src/VERSION vide -> /assets/css/admin.css?v=1790497981 -> immutable (repli, adresse valide) ``` ## Tests (ecrits avant le code) `tests/Unit/Core/AssetTest.php`, 14 cas : presence du marqueur, changement de version, stabilite, `src/VERSION` absent / vide / corrompu, adresse deja porteuse d'une chaine de requete, chemin remontant, jamais de marqueur vide, plus la garde d'usage sur les vues. Suite complete : PHPUnit 1698 tests verts, PHPStan niveau 6 sans erreur, 356 tests JS verts. ## Ce que ce lot ne couvre pas - **La borne reste non versionnee.** `src/public/borne/*.html` est du HTML statique servi tel quel : sans etape de construction, ses adresses ne peuvent pas porter de marqueur. Elle retombe sur `no-cache` -- correct (le defaut est corrige), juste moins econome en allers-retours. - **Un `git pull` a la main qui contourne `scripts/deploy.sh`** laisserait `src/VERSION` inchange : le marqueur ne bougerait pas alors que le code a change. Le chemin documente (`deploy.sh`, aussi declenche par le CD) reecrit toujours `src/VERSION`. - **La CSP n'est pas touchee** (`script-src 'self'`) : le marqueur est une chaine de requete, aucun script en ligne n'a ete introduit. - Aucune empreinte de contenu dans le NOM de fichier (le marqueur reste une chaine de requete) et aucun cache serveur/CDN n'est introduit.
fix: serve a fresh JavaScript after a deployment (cache policy + asset version marker)
All checks were successful
CI / secret-scan (push) Successful in 23s
CI / php-lint (push) Successful in 25s
CI / static-tests (push) Successful in 2m32s
CI / js-tests (push) Successful in 41s
CI / shell-tests (push) Successful in 7s
CI / secret-scan (pull_request) Successful in 19s
CI / php-lint (pull_request) Successful in 27s
CI / static-tests (pull_request) Successful in 2m35s
CI / js-tests (pull_request) Successful in 39s
CI / shell-tests (pull_request) Successful in 7s
c86a37a463
Static files were referenced by a fixed address (/assets/js/product-recipe.js)
and served with no Cache-Control header at all. Without that header a browser
applies heuristic freshness and reuses the file for hours without revalidating,
so after the back-office rework a browser could run the old JavaScript against
the new HTML: buttons did nothing, with no visible error.

- docker/apache/cache.conf: one explicit policy, included by both vhosts.
  A versioned address (?v=...) is immutable for a year; a fixed address is
  revalidated (code) or kept a week (media, fonts); HTML and PHP documents are
  never reused without revalidation.
- App\Core\Asset: single helper building a static file address with a version
  marker, injected as $asset() into every view by App\Core\Controller. The
  marker is the deployed short SHA from src/VERSION (written by deploy.sh, also
  reported by /api/health); it falls back to the file modification time when
  that file is absent (local install, test stack), and to no marker at all when
  nothing can be measured.
Corentin scheduled this pull request to auto merge when all checks succeed 2026-09-27 10:54:00 +02:00
Corentin deleted branch fix/asset-cache-busting 2026-09-27 11:01:37 +02:00
Sign in to join this conversation.
No reviewers
No labels
auto-merge
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
AcadeNice/corentin_wakdo!173
No description provided.