Skip to content

Commit 7fdca12

Browse files
sylvinusjbpenrath
authored andcommitted
✨(build) add cache-busting source version in build
1 parent 31144b1 commit 7fdca12

8 files changed

Lines changed: 89 additions & 24 deletions

File tree

.github/workflows/messages-ghcr.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,12 @@ jobs:
100100
context: "src/frontend"
101101
target: runtime-prod
102102
arm64_reuse_amd64_build_arg: "FRONTEND_IMAGE"
103+
# Bakes the commit SHA into the build as the asset cache-buster
104+
# (vite.config.ts → __SOURCE_VERSION__); without it the build falls back to a
105+
# timestamp. The src/frontend context ships no .git, so this is the only
106+
# way the image gets the real SHA. SOURCE_VERSION matches the var name
107+
# Scalingo's buildpack sets natively, so both deploy paths agree.
108+
build_args: "SOURCE_VERSION=${{ github.sha }}"
103109

104110
docker-publish-backend:
105111
needs: docker-publish-python-uv

src/frontend/Dockerfile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ COPY --chown=${DOCKER_USER} . ./
4141
ARG API_ORIGIN
4242
ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
4343

44+
# Commit SHA (or any per-deploy version) baked in as the asset cache-buster —
45+
# see vite.config.ts. Named SOURCE_VERSION to match the var Scalingo's buildpack
46+
# sets natively. Pass `--build-arg SOURCE_VERSION=$(git rev-parse --short HEAD)`;
47+
# if omitted the build falls back to a timestamp (still unique per deploy).
48+
ARG SOURCE_VERSION
49+
ENV SOURCE_VERSION=${SOURCE_VERSION}
50+
4451
RUN npm run build
4552

4653
# Normalize output path to /app (matches the runtime-prod layout)

src/frontend/caddy/Caddyfile

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,17 @@
4747
header_up X-Forwarded-For {remote_host}
4848
}
4949

50+
# --- Cache policy for the static SPA output ---
51+
# Vite fingerprints everything under /assets, so a given URL's bytes
52+
# never change: cache it forever. A new deploy emits new hashed URLs.
53+
@immutable path /assets/*
54+
header @immutable Cache-Control "public, max-age=31536000, immutable"
55+
# The SPA shell and other non-fingerprinted files (index.html, the
56+
# /locales JSON, images) must revalidate every load, or a deploy's new
57+
# asset hashes / translations only show up after a manual hard refresh.
58+
@revalidate not path /assets/*
59+
header @revalidate Cache-Control "no-cache"
60+
5061
# SPA fallback: any unmatched request returns index.html so the
5162
# client-side router can resolve the path.
5263
try_files {path} /index.html

src/frontend/public/locales/common/fr-FR.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -332,9 +332,9 @@
332332
"Check DNS again": "Revérifier les DNS",
333333
"Check for new mail regularly": "Synchroniser ce compte régulièrement",
334334
"Checking DNS records...": "Vérification des enregistrements DNS...",
335-
"Checking every {{count}} min_one": "Synchronisation toutes les {{count}} min",
336-
"Checking every {{count}} min_many": "Synchronisation toutes les {{count}} min",
337-
"Checking every {{count}} min_other": "Synchronisation toutes les {{count}} min",
335+
"Checking every {{count}} min_one": "Synchro toutes les {{count}} min",
336+
"Checking every {{count}} min_many": "Synchro toutes les {{count}} min",
337+
"Checking every {{count}} min_other": "Synchro toutes les {{count}} min",
338338
"Choose calendar": "Choisir l'agenda",
339339
"Choose the type of integration you want to create": "Choisissez le type d'intégration que vous souhaitez créer",
340340
"Clear filters": "Retirer les filtres",

src/frontend/src/features/i18n/initI18n.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ export const initI18n = (config: AppConfig) => {
4343
returnEmptyString: false,
4444
backend: {
4545
loadPath: "/locales/{{ns}}/{{lng}}.json",
46+
// Cache-bust the static locale files on every deploy: their URLs are
47+
// otherwise fixed, so a CDN/browser keeps serving a stale copy and any
48+
// newly added translation falls back to English (a half-translated UI).
49+
queryStringParams: { v: __SOURCE_VERSION__ },
4650
}
4751
})
4852
.catch((error) => {

src/frontend/src/vite-env.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,7 @@ interface ImportMetaEnv {
4444
interface ImportMeta {
4545
readonly env: ImportMetaEnv;
4646
}
47+
48+
// Per-build version stamp injected by Vite (see vite.config.ts `define`). Used
49+
// to cache-bust runtime-loaded assets such as the locale JSON files.
50+
declare const __SOURCE_VERSION__: string;

src/frontend/vite.config.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { execSync } from 'node:child_process';
12
import { defineConfig, type Plugin } from 'vite';
23
import react from '@vitejs/plugin-react';
34
import legacy from '@vitejs/plugin-legacy';
@@ -10,8 +11,32 @@ import pkg from './package.json';
1011
// Single source of truth for supported browsers; drives which polyfills the
1112
// legacy plugin injects (see the `legacy` plugin below).
1213
const browserslist = pkg.browserslist;
14+
// A per-build version stamp, baked in as `__SOURCE_VERSION__`. Used to cache-bust
15+
// the runtime-fetched locale JSONs (their URLs are otherwise fixed, so a CDN /
16+
// browser keeps serving a stale copy after a deploy — a half-translated UI).
17+
// Resolution order, most authoritative first:
18+
// - SOURCE_VERSION commit SHA — injected by Scalingo's buildpack natively,
19+
// and passed as a build-arg from our CI Docker build
20+
// - `git` local builds / any context that ships the .git dir
21+
// - timestamp last-resort, still unique from one deploy to the next
22+
const appVersion =
23+
process.env.SOURCE_VERSION ||
24+
(() => {
25+
try {
26+
return execSync('git rev-parse --short HEAD', {
27+
stdio: ['ignore', 'pipe', 'ignore'],
28+
})
29+
.toString()
30+
.trim();
31+
} catch {
32+
return `t${Date.now()}`;
33+
}
34+
})();
1335

1436
export default defineConfig({
37+
define: {
38+
__SOURCE_VERSION__: JSON.stringify(appVersion),
39+
},
1540
plugins: [
1641
// See ./tsr.config.json for tanstackRouter config
1742
tanstackRouter(),

src/frontend/vitest.config.ts

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,32 @@
1-
import { defineConfig } from 'vitest/config';
2-
import path from 'path';
1+
import { defineConfig, mergeConfig } from 'vitest/config';
32

4-
export default defineConfig({
5-
test: {
6-
globals: true,
7-
environment: 'jsdom',
8-
setupFiles: ['./vitest.setup.ts'],
9-
include: ['**/*.test.{ts,tsx}'],
10-
coverage: {
11-
provider: 'v8',
12-
reporter: ['text', 'json', 'html'],
13-
reportsDirectory: '.coverage',
3+
import viteConfig from './vite.config';
4+
5+
export default mergeConfig(
6+
// Inherit the app's build config — `define`, `resolve.alias`, `envPrefix` —
7+
// so tests compile modules exactly like the app does and can't drift from it.
8+
// Plugins are dropped rather than merged: the router codegen, the legacy
9+
// polyfill pass and the bundle analyzer are build-time concerns that only
10+
// slow the runner down. `mergeConfig` concatenates arrays, so the reset has
11+
// to happen before the merge, not in the override below.
12+
{ ...viteConfig, plugins: [] },
13+
defineConfig({
14+
// Pin the build stamp: vite.config.ts derives it from the git HEAD (or a
15+
// timestamp when .git is absent, as in the test container), which would
16+
// make every run compile to different output for no benefit.
17+
define: {
18+
__SOURCE_VERSION__: JSON.stringify('test'),
1419
},
15-
},
16-
resolve: {
17-
alias: {
18-
'@': path.resolve(__dirname, './src'),
20+
test: {
21+
globals: true,
22+
environment: 'jsdom',
23+
setupFiles: ['./vitest.setup.ts'],
24+
include: ['**/*.test.{ts,tsx}'],
25+
coverage: {
26+
provider: 'v8',
27+
reporter: ['text', 'json', 'html'],
28+
reportsDirectory: '.coverage',
29+
},
1930
},
20-
},
21-
// Keep parity with vite.config.ts so tests can stub the NEXT_PUBLIC_*
22-
// deprecated fallbacks via import.meta.env.
23-
envPrefix: 'NEXT_PUBLIC_',
24-
});
31+
}),
32+
);

0 commit comments

Comments
 (0)