fix(docker): bake CI tag into image, prefer it at runtime

The v1.4.42 docker-publish workflow shipped a stale package.json version
baked into the bundle because BuildKit reused the pnpm build layer
across the release tag bump -- the COPY . . layer key was content-stable
relative to the prior build, the layer was warm, and the runtime
/api/version read the cached package.json content instead of the new
tag.

Thread github.ref_name through as a NEXT_PUBLIC_APP_VERSION build-arg
from docker-publish.yml. The Dockerfile picks it up as an ARG/ENV in
both the builder and runner stages and bakes it into the build context.
src/app/api/version/route.ts now reads the env var first and falls
through to package.json only when unset (local pnpm dev). The build-arg
becomes part of the layer cache key, so every release tag invalidates
every downstream layer and the runtime version can never drift from
the tag the workflow ran on.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 21:46:12 +02:00
1 parent f9cf396f2e
commit 938d840670
4 files changed
+58 -1

No files matched your search

+9
View File
@@ -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: |
+19
View File
@@ -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
@@ -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<Response>)();
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<Response>)();
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<Response>)();
+9 -1
View File
@@ -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();