fix(docker): take the version from the image, not from the container's env

Portainer's recreate and a copied run 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 clients never handed over to the new
bundle. The Dockerfile now writes the version into server/VERSION, which
the server reads before APP_VERSION, and prerelease bundles take their
version from the same build argument.
This commit is contained in:
Maurice
2026-10-07 15:57:52 +02:00
parent c956fe92ba
commit c6b6b83d38
6 changed files with 108 additions and 7 deletions
+1
View File
@@ -4,6 +4,7 @@ node_modules/
# Build output
client/dist/
server/dist/
server/VERSION
shared/dist/
server/public/*
!server/public/.gitkeep
+10 -1
View File
@@ -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
+5 -4
View File
@@ -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
+8 -2
View File
@@ -29,6 +29,7 @@ import {
resolveSessionTtlMs,
stripTrailingSlashes,
} from './parsers';
import { imageVersion } from './image-version';
export type RawEnv = Record<string, string | undefined>;
@@ -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,
+33
View File
@@ -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;
}
@@ -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');
});
});