diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 4473b70d1..2622a05c9 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -194,6 +194,15 @@ jobs: file: ./Dockerfile platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} + # v1.4.43 B11 — thread the tag ref into the build as + # NEXT_PUBLIC_APP_VERSION. The Dockerfile picks it up as an + # ARG/ENV and /api/version reads it at runtime. The build-arg + # becomes part of the BuildKit layer cache key, so a release + # tag bump invalidates every downstream layer that depends on + # it (`pnpm build`) and the prior release's version string + # can never survive a cache hit into the new image. + build-args: | + NEXT_PUBLIC_APP_VERSION=${{ github.ref_name }} outputs: | type=image,name=${{ env.IMAGE_REF }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} cache-from: | diff --git a/Dockerfile b/Dockerfile index 5678c48b0..3dc224e2d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,17 @@ FROM node:22-alpine AS builder RUN corepack enable && corepack prepare pnpm@latest --activate WORKDIR /app +# v1.4.43 B11 — version build-arg threaded into the layer cache key. +# Pre-v1.4.43 the docker-publish workflow shipped a stale package.json +# version baked into the bundle because BuildKit reused the `pnpm build` +# layer across releases (the COPY . . layer key was content-stable when +# only the version string had changed and the next pnpm install layer +# was warm). Passing the tag as a build-arg forces a per-release cache +# miss on this layer and forwards the value into the runtime env so +# /api/version reads from $NEXT_PUBLIC_APP_VERSION first. +ARG NEXT_PUBLIC_APP_VERSION +ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION + COPY --from=deps /app/node_modules ./node_modules COPY . . @@ -35,6 +46,14 @@ ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_PATH="/opt/pg-boss/node_modules" ENV TZ=Europe/Berlin +# v1.4.43 B11 — forward NEXT_PUBLIC_APP_VERSION from the builder stage +# into the runner so /api/version reads the build-arg value instead of +# the package.json fallback. The variable is keyed to the CI tag ref +# (`v1.4.43`, etc.), so a release that bumps the tag but cache-reuses +# the prior `pnpm build` layer still surfaces the right runtime version. +ARG NEXT_PUBLIC_APP_VERSION +ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION + RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs diff --git a/src/app/api/version/__tests__/route.test.ts b/src/app/api/version/__tests__/route.test.ts index 93c69ca25..bd913b466 100644 --- a/src/app/api/version/__tests__/route.test.ts +++ b/src/app/api/version/__tests__/route.test.ts @@ -14,6 +14,7 @@ import { GET } from "../route"; beforeEach(() => { delete process.env.NEXT_PUBLIC_APP_BUILD_SHA; delete process.env.NEXT_PUBLIC_APP_BUILT_AT; + delete process.env.NEXT_PUBLIC_APP_VERSION; offlineGeoReadyMock.mockReturnValue(false); }); @@ -57,6 +58,26 @@ describe("GET /api/version", () => { expect(body.data.builtAt).toBe("2026-05-08T12:00:00.000Z"); }); + it("prefers NEXT_PUBLIC_APP_VERSION over package.json when set (v1.4.43 B11)", async () => { + // Build-arg-injected version wins over the bundled package.json so a + // BuildKit layer-cache hit on `pnpm build` cannot ship a stale version + // string. The CI tag ref (`v1.4.43`, etc.) is forwarded straight to + // the env so the runtime answer can never drift from the release tag. + process.env.NEXT_PUBLIC_APP_VERSION = "v1.4.43"; + const response = await (GET as unknown as () => Promise)(); + const body = (await response.json()) as VersionEnvelope; + expect(body.data.version).toBe("v1.4.43"); + }); + + it("falls through to package.json when NEXT_PUBLIC_APP_VERSION is blank", async () => { + // A whitespace-only env value must NOT shadow the package.json read -- + // the trim()+OR pattern ensures the fallback still kicks in. + process.env.NEXT_PUBLIC_APP_VERSION = " "; + const response = await (GET as unknown as () => Promise)(); + const body = (await response.json()) as VersionEnvelope; + expect(body.data.version).toMatch(/^\d+\.\d+\.\d+(\.\d+)?(-[a-z0-9.-]+)?$/i); + }); + it("reports offlineGeoEnabled=false when the GeoLite2 databases are absent", async () => { offlineGeoReadyMock.mockReturnValue(false); const response = await (GET as unknown as () => Promise)(); diff --git a/src/app/api/version/route.ts b/src/app/api/version/route.ts index 68ed0bc66..c5d6029bc 100644 --- a/src/app/api/version/route.ts +++ b/src/app/api/version/route.ts @@ -37,7 +37,15 @@ export const dynamic = "force-dynamic"; * first use. */ export const GET = apiHandler(async () => { - const version = packageJson.version; + // v1.4.43 B11 — prefer the build-arg-injected env var so the runtime + // version cannot drift from the CI release tag. The Dockerfile takes + // `NEXT_PUBLIC_APP_VERSION` as a build arg from the docker-publish + // workflow (`build-args: NEXT_PUBLIC_APP_VERSION=${{ github.ref_name + // }}`); when present it wins over the package.json fallback so a + // BuildKit-layer cache hit on `pnpm build` can never re-ship the + // prior release's version string. Local `pnpm dev` (env unset) falls + // through to package.json as before. + const version = process.env.NEXT_PUBLIC_APP_VERSION?.trim() || packageJson.version; const buildSha = process.env.NEXT_PUBLIC_APP_BUILD_SHA?.trim() || null; const builtAt = process.env.NEXT_PUBLIC_APP_BUILT_AT?.trim() || null; const offlineGeoEnabled = offlineGeoReady();