From 7fdca124261e22edf9f695577714052ac73deaec Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Tue, 21 Jul 2026 23:09:26 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(build)=20add=20cache-busting=20source?= =?UTF-8?q?=20version=20in=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/messages-ghcr.yml | 6 +++ src/frontend/Dockerfile | 7 +++ src/frontend/caddy/Caddyfile | 11 ++++ src/frontend/public/locales/common/fr-FR.json | 6 +-- src/frontend/src/features/i18n/initI18n.ts | 4 ++ src/frontend/src/vite-env.d.ts | 4 ++ src/frontend/vite.config.ts | 25 ++++++++++ src/frontend/vitest.config.ts | 50 +++++++++++-------- 8 files changed, 89 insertions(+), 24 deletions(-) diff --git a/.github/workflows/messages-ghcr.yml b/.github/workflows/messages-ghcr.yml index dd187700..42e94652 100644 --- a/.github/workflows/messages-ghcr.yml +++ b/.github/workflows/messages-ghcr.yml @@ -100,6 +100,12 @@ jobs: context: "src/frontend" target: runtime-prod arm64_reuse_amd64_build_arg: "FRONTEND_IMAGE" + # Bakes the commit SHA into the build as the asset cache-buster + # (vite.config.ts → __SOURCE_VERSION__); without it the build falls back to a + # timestamp. The src/frontend context ships no .git, so this is the only + # way the image gets the real SHA. SOURCE_VERSION matches the var name + # Scalingo's buildpack sets natively, so both deploy paths agree. + build_args: "SOURCE_VERSION=${{ github.sha }}" docker-publish-backend: needs: docker-publish-python-uv diff --git a/src/frontend/Dockerfile b/src/frontend/Dockerfile index d9026ef1..1b3f43e1 100644 --- a/src/frontend/Dockerfile +++ b/src/frontend/Dockerfile @@ -41,6 +41,13 @@ COPY --chown=${DOCKER_USER} . ./ ARG API_ORIGIN ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN} +# Commit SHA (or any per-deploy version) baked in as the asset cache-buster — +# see vite.config.ts. Named SOURCE_VERSION to match the var Scalingo's buildpack +# sets natively. Pass `--build-arg SOURCE_VERSION=$(git rev-parse --short HEAD)`; +# if omitted the build falls back to a timestamp (still unique per deploy). +ARG SOURCE_VERSION +ENV SOURCE_VERSION=${SOURCE_VERSION} + RUN npm run build # Normalize output path to /app (matches the runtime-prod layout) diff --git a/src/frontend/caddy/Caddyfile b/src/frontend/caddy/Caddyfile index 0dbf139b..78f28416 100644 --- a/src/frontend/caddy/Caddyfile +++ b/src/frontend/caddy/Caddyfile @@ -47,6 +47,17 @@ header_up X-Forwarded-For {remote_host} } + # --- Cache policy for the static SPA output --- + # Vite fingerprints everything under /assets, so a given URL's bytes + # never change: cache it forever. A new deploy emits new hashed URLs. + @immutable path /assets/* + header @immutable Cache-Control "public, max-age=31536000, immutable" + # The SPA shell and other non-fingerprinted files (index.html, the + # /locales JSON, images) must revalidate every load, or a deploy's new + # asset hashes / translations only show up after a manual hard refresh. + @revalidate not path /assets/* + header @revalidate Cache-Control "no-cache" + # SPA fallback: any unmatched request returns index.html so the # client-side router can resolve the path. try_files {path} /index.html diff --git a/src/frontend/public/locales/common/fr-FR.json b/src/frontend/public/locales/common/fr-FR.json index 03be1b41..c9ac8f5f 100644 --- a/src/frontend/public/locales/common/fr-FR.json +++ b/src/frontend/public/locales/common/fr-FR.json @@ -332,9 +332,9 @@ "Check DNS again": "Revérifier les DNS", "Check for new mail regularly": "Synchroniser ce compte régulièrement", "Checking DNS records...": "Vérification des enregistrements DNS...", - "Checking every {{count}} min_one": "Synchronisation toutes les {{count}} min", - "Checking every {{count}} min_many": "Synchronisation toutes les {{count}} min", - "Checking every {{count}} min_other": "Synchronisation toutes les {{count}} min", + "Checking every {{count}} min_one": "Synchro toutes les {{count}} min", + "Checking every {{count}} min_many": "Synchro toutes les {{count}} min", + "Checking every {{count}} min_other": "Synchro toutes les {{count}} min", "Choose calendar": "Choisir l'agenda", "Choose the type of integration you want to create": "Choisissez le type d'intégration que vous souhaitez créer", "Clear filters": "Retirer les filtres", diff --git a/src/frontend/src/features/i18n/initI18n.ts b/src/frontend/src/features/i18n/initI18n.ts index 3db203c4..e3df2201 100644 --- a/src/frontend/src/features/i18n/initI18n.ts +++ b/src/frontend/src/features/i18n/initI18n.ts @@ -43,6 +43,10 @@ export const initI18n = (config: AppConfig) => { returnEmptyString: false, backend: { loadPath: "/locales/{{ns}}/{{lng}}.json", + // Cache-bust the static locale files on every deploy: their URLs are + // otherwise fixed, so a CDN/browser keeps serving a stale copy and any + // newly added translation falls back to English (a half-translated UI). + queryStringParams: { v: __SOURCE_VERSION__ }, } }) .catch((error) => { diff --git a/src/frontend/src/vite-env.d.ts b/src/frontend/src/vite-env.d.ts index 41e527df..829c2b6f 100644 --- a/src/frontend/src/vite-env.d.ts +++ b/src/frontend/src/vite-env.d.ts @@ -44,3 +44,7 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv; } + +// Per-build version stamp injected by Vite (see vite.config.ts `define`). Used +// to cache-bust runtime-loaded assets such as the locale JSON files. +declare const __SOURCE_VERSION__: string; diff --git a/src/frontend/vite.config.ts b/src/frontend/vite.config.ts index 9a82a4bd..7bfc8782 100644 --- a/src/frontend/vite.config.ts +++ b/src/frontend/vite.config.ts @@ -1,3 +1,4 @@ +import { execSync } from 'node:child_process'; import { defineConfig, type Plugin } from 'vite'; import react from '@vitejs/plugin-react'; import legacy from '@vitejs/plugin-legacy'; @@ -10,8 +11,32 @@ import pkg from './package.json'; // Single source of truth for supported browsers; drives which polyfills the // legacy plugin injects (see the `legacy` plugin below). const browserslist = pkg.browserslist; +// A per-build version stamp, baked in as `__SOURCE_VERSION__`. Used to cache-bust +// the runtime-fetched locale JSONs (their URLs are otherwise fixed, so a CDN / +// browser keeps serving a stale copy after a deploy — a half-translated UI). +// Resolution order, most authoritative first: +// - SOURCE_VERSION commit SHA — injected by Scalingo's buildpack natively, +// and passed as a build-arg from our CI Docker build +// - `git` local builds / any context that ships the .git dir +// - timestamp last-resort, still unique from one deploy to the next +const appVersion = + process.env.SOURCE_VERSION || + (() => { + try { + return execSync('git rev-parse --short HEAD', { + stdio: ['ignore', 'pipe', 'ignore'], + }) + .toString() + .trim(); + } catch { + return `t${Date.now()}`; + } + })(); export default defineConfig({ + define: { + __SOURCE_VERSION__: JSON.stringify(appVersion), + }, plugins: [ // See ./tsr.config.json for tanstackRouter config tanstackRouter(), diff --git a/src/frontend/vitest.config.ts b/src/frontend/vitest.config.ts index f7a15752..49e2d0d7 100644 --- a/src/frontend/vitest.config.ts +++ b/src/frontend/vitest.config.ts @@ -1,24 +1,32 @@ -import { defineConfig } from 'vitest/config'; -import path from 'path'; +import { defineConfig, mergeConfig } from 'vitest/config'; -export default defineConfig({ - test: { - globals: true, - environment: 'jsdom', - setupFiles: ['./vitest.setup.ts'], - include: ['**/*.test.{ts,tsx}'], - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - reportsDirectory: '.coverage', +import viteConfig from './vite.config'; + +export default mergeConfig( + // Inherit the app's build config — `define`, `resolve.alias`, `envPrefix` — + // so tests compile modules exactly like the app does and can't drift from it. + // Plugins are dropped rather than merged: the router codegen, the legacy + // polyfill pass and the bundle analyzer are build-time concerns that only + // slow the runner down. `mergeConfig` concatenates arrays, so the reset has + // to happen before the merge, not in the override below. + { ...viteConfig, plugins: [] }, + defineConfig({ + // Pin the build stamp: vite.config.ts derives it from the git HEAD (or a + // timestamp when .git is absent, as in the test container), which would + // make every run compile to different output for no benefit. + define: { + __SOURCE_VERSION__: JSON.stringify('test'), }, - }, - resolve: { - alias: { - '@': path.resolve(__dirname, './src'), + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./vitest.setup.ts'], + include: ['**/*.test.{ts,tsx}'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + reportsDirectory: '.coverage', + }, }, - }, - // Keep parity with vite.config.ts so tests can stub the NEXT_PUBLIC_* - // deprecated fallbacks via import.meta.env. - envPrefix: 'NEXT_PUBLIC_', -}); \ No newline at end of file + }), +);