From 5759b93dda5d202cf34f3663d9c70b95291fa34d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 02:55:09 +0000 Subject: [PATCH] fix(ci): pin multi-arch docker base digests --- Dockerfile | 20 ++++++++----- docs/install/docker.md | 5 ++-- src/docker-image-digests.test.ts | 49 ++++++++++++++++++++++++++++---- src/dockerfile.test.ts | 16 +++++++++++ 4 files changed, 75 insertions(+), 15 deletions(-) diff --git a/Dockerfile b/Dockerfile index a4a98e305e1..3ef9421b589 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,13 +12,17 @@ # Slim (bookworm-slim): docker build --build-arg OPENCLAW_VARIANT=slim . ARG OPENCLAW_EXTENSIONS="" ARG OPENCLAW_VARIANT=default +ARG OPENCLAW_NODE_BOOKWORM_IMAGE="node:22-bookworm@sha256:b501c082306a4f528bc4038cbf2fbb58095d583d0419a259b2114b5ac53d12e9" +ARG OPENCLAW_NODE_BOOKWORM_DIGEST="sha256:b501c082306a4f528bc4038cbf2fbb58095d583d0419a259b2114b5ac53d12e9" +ARG OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE="node:22-bookworm-slim@sha256:9c2c405e3ff9b9afb2873232d24bb06367d649aa3e6259cbe314da59578e81e9" +ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:9c2c405e3ff9b9afb2873232d24bb06367d649aa3e6259cbe314da59578e81e9" # Base images are pinned to SHA256 digests for reproducible builds. # Trade-off: digests must be updated manually when upstream tags move. # To update, run: docker manifest inspect node:22-bookworm (or podman) -# and replace the digest below with the current amd64 entry. +# and replace the digest below with the current multi-arch manifest list entry. -FROM node:22-bookworm@sha256:6d735b4d33660225271fda0a412802746658c3a1b975507b2803ed299609760a AS ext-deps +FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS ext-deps ARG OPENCLAW_EXTENSIONS COPY extensions /tmp/extensions # Copy package.json for opted-in extensions so pnpm resolves their deps. @@ -31,7 +35,7 @@ RUN mkdir -p /out && \ done # ── Stage 2: Build ────────────────────────────────────────────── -FROM node:22-bookworm@sha256:6d735b4d33660225271fda0a412802746658c3a1b975507b2803ed299609760a AS build +FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS build # Install Bun (required for build scripts) RUN curl -fsSL https://bun.sh/install | bash @@ -69,13 +73,15 @@ ENV OPENCLAW_PREFER_PNPM=1 RUN pnpm ui:build # ── Runtime base images ───────────────────────────────────────── -FROM node:22-bookworm@sha256:6d735b4d33660225271fda0a412802746658c3a1b975507b2803ed299609760a AS base-default +FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS base-default +ARG OPENCLAW_NODE_BOOKWORM_DIGEST LABEL org.opencontainers.image.base.name="docker.io/library/node:22-bookworm" \ - org.opencontainers.image.base.digest="sha256:6d735b4d33660225271fda0a412802746658c3a1b975507b2803ed299609760a" + org.opencontainers.image.base.digest="${OPENCLAW_NODE_BOOKWORM_DIGEST}" -FROM node:22-bookworm-slim@sha256:b41c15b715b5d6e3f305e9c6480a2396dd5f130b63add98d3d45760376f20823 AS base-slim +FROM ${OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE} AS base-slim +ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST LABEL org.opencontainers.image.base.name="docker.io/library/node:22-bookworm-slim" \ - org.opencontainers.image.base.digest="sha256:b41c15b715b5d6e3f305e9c6480a2396dd5f130b63add98d3d45760376f20823" + org.opencontainers.image.base.digest="${OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST}" # ── Stage 3: Runtime ──────────────────────────────────────────── FROM base-${OPENCLAW_VARIANT} diff --git a/docs/install/docker.md b/docs/install/docker.md index b3d3daf798d..c6337c3db48 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -167,10 +167,11 @@ The main Docker image currently uses: - `node:22-bookworm` -The docker image now publishes OCI base-image annotations (sha256 is an example): +The docker image now publishes OCI base-image annotations (sha256 is an example, +and points at the pinned multi-arch manifest list for that tag): - `org.opencontainers.image.base.name=docker.io/library/node:22-bookworm` -- `org.opencontainers.image.base.digest=sha256:6d735b4d33660225271fda0a412802746658c3a1b975507b2803ed299609760a` +- `org.opencontainers.image.base.digest=sha256:b501c082306a4f528bc4038cbf2fbb58095d583d0419a259b2114b5ac53d12e9` - `org.opencontainers.image.source=https://github.com/openclaw/openclaw` - `org.opencontainers.image.url=https://openclaw.ai` - `org.opencontainers.image.documentation=https://docs.openclaw.ai/install/docker` diff --git a/src/docker-image-digests.test.ts b/src/docker-image-digests.test.ts index d62a46434e1..024cd9df7dc 100644 --- a/src/docker-image-digests.test.ts +++ b/src/docker-image-digests.test.ts @@ -33,16 +33,53 @@ type DependabotConfig = { updates?: DependabotUpdate[]; }; +function resolveFirstFromReference(dockerfile: string): string | undefined { + const argDefaults = new Map(); + + for (const line of dockerfile.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + if (trimmed.startsWith("FROM ")) { + break; + } + const argMatch = trimmed.match(/^ARG\s+([A-Z0-9_]+)=(.+)$/); + if (!argMatch) { + continue; + } + const [, name, rawValue] = argMatch; + const value = rawValue.replace(/^["']|["']$/g, ""); + argDefaults.set(name, value); + } + + const fromLine = dockerfile.split(/\r?\n/).find((line) => line.trimStart().startsWith("FROM ")); + if (!fromLine) { + return undefined; + } + + const fromMatch = fromLine.trim().match(/^FROM\s+(\S+?)(?:\s+AS\s+\S+)?$/); + if (!fromMatch) { + return undefined; + } + const imageRef = fromMatch[1]; + const argName = + imageRef.match(/^\$\{([A-Z0-9_]+)\}$/)?.[1] ?? imageRef.match(/^\$([A-Z0-9_]+)$/)?.[1]; + + if (!argName) { + return imageRef; + } + return argDefaults.get(argName); +} + describe("docker base image pinning", () => { it("pins selected Dockerfile FROM lines to immutable sha256 digests", async () => { for (const dockerfilePath of DIGEST_PINNED_DOCKERFILES) { const dockerfile = await readFile(resolve(repoRoot, dockerfilePath), "utf8"); - const fromLine = dockerfile - .split(/\r?\n/) - .find((line) => line.trimStart().startsWith("FROM ")); - expect(fromLine, `${dockerfilePath} should define a FROM line`).toBeDefined(); - expect(fromLine, `${dockerfilePath} FROM must be digest-pinned`).toMatch( - /^FROM\s+\S+@sha256:[a-f0-9]{64}(?:\s+AS\s+\S+)?$/, + const imageRef = resolveFirstFromReference(dockerfile); + expect(imageRef, `${dockerfilePath} should define a FROM line`).toBeDefined(); + expect(imageRef, `${dockerfilePath} FROM must be digest-pinned`).toMatch( + /^\S+@sha256:[a-f0-9]{64}$/, ); } }); diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index 4600e446a61..4dd41398e5a 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -7,6 +7,22 @@ const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), ".."); const dockerfilePath = join(repoRoot, "Dockerfile"); describe("Dockerfile", () => { + it("uses shared multi-arch base image refs for all root Node stages", async () => { + const dockerfile = await readFile(dockerfilePath, "utf8"); + expect(dockerfile).toContain( + 'ARG OPENCLAW_NODE_BOOKWORM_IMAGE="node:22-bookworm@sha256:b501c082306a4f528bc4038cbf2fbb58095d583d0419a259b2114b5ac53d12e9"', + ); + expect(dockerfile).toContain( + 'ARG OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE="node:22-bookworm-slim@sha256:9c2c405e3ff9b9afb2873232d24bb06367d649aa3e6259cbe314da59578e81e9"', + ); + expect(dockerfile).toContain("FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS ext-deps"); + expect(dockerfile).toContain("FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS build"); + expect(dockerfile).toContain("FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS base-default"); + expect(dockerfile).toContain("FROM ${OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE} AS base-slim"); + expect(dockerfile).toContain("current multi-arch manifest list entry"); + expect(dockerfile).not.toContain("current amd64 entry"); + }); + it("installs optional browser dependencies after pnpm install", async () => { const dockerfile = await readFile(dockerfilePath, "utf8"); const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile");