build: run the image on the official Node 22 Debian slim image

The runner moves from Wolfi to node:22-trixie-slim, the same image and
digest as the dependency and build stages. Wolfi's free repository keeps
only its newest Node stream current, so nodejs-22 stayed at 22.23.2 and
missed two releases with HIGH fixes; the official image carries 22.23.4.

The runner removes npm, npx, corepack and yarn before anything else and
fails the build if one is left. The Prisma CLI install moves to its own
prisma-cli stage on the same base and is copied over, so npm never runs
in the runner. pg_dump and pg_restore 18.6 come from a pgclient stage on
postgres:18.6-trixie, pinned by digest, with the libpq they link; the
Kerberos, LDAP and SASL libraries come from apt. The PGDG package would
bring Perl and the wrapper scripts, and its repository drops a pinned
minor once the next one is out.

The native ABI and glibc check between builder and runner stays as a
guard. runtime-package-pins now holds every stage to a digest, the
runner to the builder's image, the runner free of package managers, and
the pgclient minor to the bundled database's. Dependabot ignores a
PostgreSQL major in the Dockerfile as it does in the compose file.
This commit is contained in:
Marc-André Bombeck committed 2026-11-04 13:13:56 +01:00
1 parent de47cb7b31
commit b6b693717c
7 files changed
+251 -137

No files matched your search

@@ -61,7 +61,7 @@ describe("canvas binary hoist", () => {
expect(runner).not.toMatch(/@napi-rs\+canvas[^"'\s]*@\*/);
});
it("installs on Debian and runs on Wolfi, both glibc", () => {
it("installs and runs on the same Debian glibc image", () => {
const froms = DOCKERFILE.match(/^FROM \S+ AS \S+$/gm) ?? [];
expect(froms).toEqual([
expect.stringMatching(
@@ -71,7 +71,13 @@ describe("canvas binary hoist", () => {
/^FROM node:22-trixie-slim@sha256:[0-9a-f]{64} AS builder$/,
),
expect.stringMatching(
/^FROM cgr\.dev\/chainguard\/wolfi-base:latest@sha256:[0-9a-f]{64} AS runner$/,
/^FROM node:22-trixie-slim@sha256:[0-9a-f]{64} AS prisma-cli$/,
),
expect.stringMatching(
/^FROM postgres:18\.\d+-trixie@sha256:[0-9a-f]{64} AS pgclient$/,
),
expect.stringMatching(
/^FROM node:22-trixie-slim@sha256:[0-9a-f]{64} AS runner$/,
),
]);
// The native packages pnpm picked in the builder must load in the
+78 -31
View File
@@ -1,14 +1,15 @@
/**
* The runtime packages the image installs from apk are pinned, and the
* PostgreSQL client moves with the bundled database.
* The runtime image is pinned by image digest, carries no package manager,
* and its PostgreSQL client moves with the bundled database.
*
* The runner installs Node and the PostgreSQL client from the Wolfi
* repository. Unpinned, a rebuild of the same commit could ship another
* Node or another pg_dump than the one the release was checked with. No
* updater reads an apk pin; the image scan is what flags a pinned package
* once Wolfi publishes a fix (the Dockerfile comment explains the bump), so
* this file only holds the pins' shape and their agreement with the rest of
* the repository.
* Node comes from the official node:22-trixie-slim image and pg_dump /
* pg_restore from the official PostgreSQL image, both by digest. Without
* the digest a rebuild of the same commit could ship another Node or
* another pg_dump than the one the release was checked with. Dependabot's
* `docker` ecosystem proposes the next digest or minor; the image scan
* names a fix the pinned base has not absorbed. This file holds the pins'
* shape, the runner's lack of npm, and the client's agreement with the
* bundled database.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
@@ -19,38 +20,84 @@ const ROOT = process.cwd();
const DOCKERFILE = readFileSync(join(ROOT, "Dockerfile"), "utf8");
const COMPOSE = readFileSync(join(ROOT, "docker-compose.yml"), "utf8");
function runner(): string {
const start = DOCKERFILE.search(/^FROM .* AS runner$/m);
expect(start, "the Dockerfile has a runner stage").toBeGreaterThan(0);
return DOCKERFILE.slice(start);
const DIGEST = /@sha256:[0-9a-f]{64}$/;
function froms(): { image: string; stage: string }[] {
return [...DOCKERFILE.matchAll(/^FROM (\S+) AS (\S+)$/gm)].map((m) => ({
image: m[1]!,
stage: m[2]!,
}));
}
function apkAdds(): string[] {
return [...runner().matchAll(/apk add --no-cache ([^&\n\\]+)/g)].flatMap(
(m) => m[1]!.trim().split(/\s+/),
);
function stage(name: string): string {
const start = DOCKERFILE.search(new RegExp(`^FROM .* AS ${name}$`, "m"));
expect(start, `the Dockerfile has a ${name} stage`).toBeGreaterThan(0);
const next = DOCKERFILE.slice(start + 1).search(/^FROM /m);
return next < 0
? DOCKERFILE.slice(start)
: DOCKERFILE.slice(start, start + 1 + next);
}
/** The runner's instructions, comments dropped. */
function runnerInstructions(): string {
return stage("runner")
.split("\n")
.filter((line) => !line.trimStart().startsWith("#"))
.join("\n");
}
describe("runtime package pins", () => {
it("pins nodejs-22 to one exact Node 22 package version", () => {
const pins = apkAdds().filter((p) => p.startsWith("nodejs"));
expect(pins).toHaveLength(1);
expect(pins[0]).toMatch(/^nodejs-22=22\.\d+\.\d+-r\d+$/);
it("pins every stage's base image by digest", () => {
const all = froms();
// Every FROM names a stage, so none escapes the list above.
expect(DOCKERFILE.match(/^FROM /gm)).toHaveLength(all.length);
for (const { image, stage: name } of all) {
expect(image, `${name} pins its base by digest`).toMatch(DIGEST);
}
});
it("pins postgresql-18-client to the bundled database's minor", () => {
const pins = apkAdds().filter((p) => p.startsWith("postgresql"));
expect(pins).toHaveLength(1);
const client = /^postgresql-18-client=(18\.\d+)-r\d+$/.exec(pins[0]!);
expect(client, `an exact pin, not ${pins[0]}`).not.toBeNull();
it("runs on the builder's own official Node 22 image", () => {
const byStage = new Map(froms().map((f) => [f.stage, f.image]));
const runner = byStage.get("runner");
expect(runner).toMatch(/^node:22-trixie-slim@sha256:[0-9a-f]{64}$/);
// Same image as the stage the native packages were installed in.
expect(runner).toBe(byStage.get("builder"));
});
it("removes npm, npx, corepack and yarn from the runner and runs none of them", () => {
const runner = runnerInstructions();
for (const path of [
"/usr/local/lib/node_modules/npm",
"/usr/local/lib/node_modules/corepack",
"/usr/local/bin/npm",
"/usr/local/bin/npx",
"/usr/local/bin/corepack",
"/usr/local/bin/yarn",
]) {
expect(runner).toContain(path);
}
expect(runner).toContain(
"sh -c '! command -v npm && ! command -v npx && ! command -v corepack && ! command -v yarn && ! command -v pnpm'",
);
// Nothing in the runner installs a package manager or runs one.
expect(runner).not.toMatch(/\b(npm|npx|corepack|yarn|pnpm) [a-z]/);
expect(runner).not.toMatch(/apt-get install[^;&]*\b(npm|nodejs)\b/);
expect(runner).not.toMatch(/\bapk\b/);
});
it("takes the PostgreSQL client from the bundled database's minor", () => {
const pg = froms().find((f) => f.stage === "pgclient");
expect(pg, "the Dockerfile has a pgclient stage").toBeDefined();
const client = /^postgres:(18\.\d+)-trixie@sha256:[0-9a-f]{64}$/.exec(
pg!.image,
);
expect(client, `a pinned major 18 image, not ${pg!.image}`).not.toBeNull();
const db = /^\s*image:\s*postgres:(\d+\.\d+)-alpine$/m.exec(COMPOSE);
expect(db, "the compose file pins the database").not.toBeNull();
// One decision: a database minor bump moves the client with it.
expect(client![1]).toBe(db![1]);
});
it("leaves no other package floating except tzdata and the build-only npm", () => {
const unpinned = apkAdds().filter((p) => !p.includes("="));
expect(new Set(unpinned)).toEqual(new Set(["tzdata", "npm"]));
expect(stage("runner")).toContain(
"COPY --from=pgclient /out/bin/ /usr/lib/postgresql/18/bin/",
);
});
});
+1 -1
View File
@@ -9,7 +9,7 @@
* restart loop (live incident: a Synology DS1520+ self-host, exit code 132
* ~18 s after every boot once the thumbnail backfill touched Skia).
*
* The published image runs on glibc (Wolfi) and loads the glibc build. Under an
* The published image runs on glibc (Debian) and loads the glibc build. Under an
* emulated AVX2-less Atom core that build drew a canvas and rendered
* a PDF page to JPEG where the musl build died, but it still carries AVX2 code
* and not every path it can take was exercised, so the x64 gate stays closed
+2 -2
View File
@@ -23,11 +23,11 @@
*
* Extraction is pure Node: `zlib.gunzipSync` + a minimal in-process tar reader
* (`extractMmdbFromTarGz`), NOT a shell-out to `tar`. The runtime image does
* carry busybox `tar`+`gzip`, but this module is reachable from the worker-boot
* carry `tar` and `gzip`, but this module is reachable from the worker-boot
* import chain (`instrumentation.ts` → `reminder-worker` → this file), and a
* `child_process` import there makes Next's file tracer give up and trace the
* whole project into the standalone output. `node:zlib` keeps the trace bounded,
* removes the busybox runtime dependency entirely, and is trivially unit-testable
* removes the dependency on those tools entirely, and is trivially unit-testable
* — the MaxMind tarball nests a single `.mmdb` under one date-stamped directory,
* a shape a ~40-line reader handles without a dependency.
*/