diff --git a/.gitignore b/.gitignore index c3aaa38a2..df2d80872 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ # Build output client/dist/ server/dist/ +server/VERSION shared/dist/ server/public/* !server/public/.gitkeep diff --git a/Dockerfile b/Dockerfile index fb2cbb33f..70b939e57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,9 @@ COPY client/scripts/patch-maplibre.mjs ./client/scripts/ RUN npm ci --workspace=client COPY --from=shared-builder /app/shared/dist ./shared/dist COPY client/ ./client/ +# A prerelease build does not bump client/package.json, so the bundle takes its +# version from the same build argument the server's VERSION file comes from. +ARG APP_VERSION=dev RUN npm run build --workspace=client # ── Stage 3: server ────────────────────────────────────────────────────────── @@ -120,8 +123,14 @@ RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/ ENV NODE_ENV=production ENV NODE_USE_ENV_PROXY=1 ENV PORT=3000 +# The version goes into a file, not into ENV. A container keeps its environment +# when it is recreated on a newer image (Portainer's recreate, a copied run +# config), so an APP_VERSION env var would keep announcing the old release and +# the clients would never pick up the new bundle. The server reads this file +# first (server/src/app-config/image-version.ts). ARG APP_VERSION=dev -ENV APP_VERSION=${APP_VERSION} +RUN if [ "$APP_VERSION" != "dev" ]; then printf '%s +' "$APP_VERSION" > /app/server/VERSION; fi # OCI metadata: Renovate, Watchtower and the registries read the source label to # link an image update to its release notes (#1498). The release workflows add diff --git a/client/vite.config.js b/client/vite.config.js index 833c50c78..40f449bdc 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -5,11 +5,12 @@ import { visualizer } from 'rollup-plugin-visualizer'; import { rtlTextAlias, plyrSpriteAlias } from './rtlTextAlias.js'; import { readFileSync } from 'node:fs'; -// The version this bundle is built as, baked in at build time. The release image -// bumps every package.json before it builds, so this matches the server's -// APP_VERSION there; a source checkout matches the server's own package.json. +// The version this bundle is built as, baked in at build time. The Docker build +// passes APP_VERSION, which the prerelease images need because they do not bump +// package.json; a source checkout matches the server's own package.json. // The server hands the release notice only to a bundle built for its version. -const UI_VERSION = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version; +const BUILD_VERSION = process.env.APP_VERSION && process.env.APP_VERSION !== 'dev' ? process.env.APP_VERSION : null; +const UI_VERSION = BUILD_VERSION ?? JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version; // `npm run build:analyze` writes dist/stats.html — a treemap of what actually ended // up in each chunk. The plain build only reports chunk sizes, which tells you a chunk diff --git a/server/src/app-config/derive.ts b/server/src/app-config/derive.ts index 419a1ff3f..0c23b4238 100644 --- a/server/src/app-config/derive.ts +++ b/server/src/app-config/derive.ts @@ -29,6 +29,7 @@ import { resolveSessionTtlMs, stripTrailingSlashes, } from './parsers'; +import { imageVersion } from './image-version'; export type RawEnv = Record; @@ -43,8 +44,13 @@ export function deriveApp(raw: RawEnv) { isTest: nodeEnv === 'test', port: numberOr(raw.PORT, 3001), host: raw.HOST, - /** Raw APP_VERSION — fallbacks differ per site ('0.0.0' vs package.json vs semver-validated); each keeps its own. */ - appVersion: raw.APP_VERSION, + /** + * The image's own VERSION file, else raw APP_VERSION. Fallbacks differ per + * site ('0.0.0' vs package.json vs semver-validated); each keeps its own. + * The file wins because a container keeps its env across image updates + * (image-version.ts). + */ + appVersion: imageVersion() ?? raw.APP_VERSION, /** Raw APP_URL — trailing-slash stripping differs per site (feeds strips one, notifications strips all). */ appUrl: raw.APP_URL, tz: raw.TZ, diff --git a/server/src/app-config/image-version.ts b/server/src/app-config/image-version.ts new file mode 100644 index 000000000..004d81092 --- /dev/null +++ b/server/src/app-config/image-version.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +/** + * The version the Docker image was built as, from the `VERSION` file the + * Dockerfile writes next to `server/package.json`. + * + * It wins over the APP_VERSION env var because an env var belongs to the + * container, not to the image. Portainer's "recreate" and `docker run` with a + * copied config carry the previous image's APP_VERSION into a container built + * from the new one, so the server kept announcing the old release and no client + * ever handed over to the new bundle. A file inside the image cannot outlive it. + * + * Read once: the file is part of the image and does not change while the + * process runs. `null` outside an image (source checkout, tests), where the env + * var and package.json keep deciding as before. + */ +let cached: string | null | undefined; + +export function imageVersion(file = resolve(__dirname, '../../VERSION')): string | null { + if (cached !== undefined) return cached; + try { + cached = readFileSync(file, 'utf8').trim() || null; + } catch { + cached = null; + } + return cached; +} + +/** Test seam: forget the cached read. */ +export function resetImageVersion(): void { + cached = undefined; +} diff --git a/server/tests/unit/app-config/image-version.test.ts b/server/tests/unit/app-config/image-version.test.ts new file mode 100644 index 000000000..b7096070e --- /dev/null +++ b/server/tests/unit/app-config/image-version.test.ts @@ -0,0 +1,51 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { deriveApp } from '../../../src/app-config/derive'; +import { imageVersion, resetImageVersion } from '../../../src/app-config/image-version'; + +describe('imageVersion', () => { + const dirs: string[] = []; + const versionFile = (content: string): string => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-image-version-')); + dirs.push(dir); + const file = path.join(dir, 'VERSION'); + fs.writeFileSync(file, content); + return file; + }; + + beforeEach(() => resetImageVersion()); + + afterEach(() => { + resetImageVersion(); + for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('IMGVER-001: reads the version the image was built as, trimmed', () => { + expect(imageVersion(versionFile('4.4.0-pre.3\n'))).toBe('4.4.0-pre.3'); + }); + + it('IMGVER-002: is null outside an image and for an empty file', () => { + expect(imageVersion(path.join(os.tmpdir(), 'trek-no-such-dir', 'VERSION'))).toBeNull(); + resetImageVersion(); + expect(imageVersion(versionFile(' \n'))).toBeNull(); + }); + + it('IMGVER-003: reads once, because the image does not change under a running process', () => { + const file = versionFile('4.3.3'); + expect(imageVersion(file)).toBe('4.3.3'); + fs.writeFileSync(file, '9.9.9'); + expect(imageVersion(file)).toBe('4.3.3'); + }); + + it("IMGVER-004: the image's version wins over an APP_VERSION a recreated container carried over", () => { + imageVersion(versionFile('4.4.0')); + expect(deriveApp({ APP_VERSION: '4.3.3' }).appVersion).toBe('4.4.0'); + }); + + it('IMGVER-005: without an image file, APP_VERSION still decides', () => { + imageVersion(path.join(os.tmpdir(), 'trek-no-such-dir', 'VERSION')); + expect(deriveApp({ APP_VERSION: '4.3.3' }).appVersion).toBe('4.3.3'); + }); +});