From 6d5e142b93120f097cd2afc2467547754b836753 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 17:57:46 -0700 Subject: [PATCH] Docker: improve build cache reuse (#40351) * Docker: improve build cache reuse * Tests: cover Docker build cache layout * Docker: fix sandbox cache mount continuations * Docker: document qr-import manifest scope * Docker: narrow e2e install inputs * CI: cache Docker builds in workflows * CI: route sandbox smoke through setup script * CI: keep sandbox smoke on script path --- .github/workflows/docker-release.yml | 8 ++ Dockerfile | 38 +++--- Dockerfile.sandbox | 9 +- Dockerfile.sandbox-browser | 9 +- Dockerfile.sandbox-common | 10 +- scripts/docker/cleanup-smoke/Dockerfile | 12 +- scripts/docker/install-sh-e2e/Dockerfile | 9 +- scripts/docker/install-sh-nonroot/Dockerfile | 9 +- scripts/docker/install-sh-smoke/Dockerfile | 9 +- scripts/e2e/Dockerfile | 14 +- scripts/e2e/Dockerfile.qr-import | 14 +- scripts/sandbox-common-setup.sh | 16 ++- src/docker-build-cache.test.ts | 127 +++++++++++++++++++ 13 files changed, 237 insertions(+), 47 deletions(-) create mode 100644 src/docker-build-cache.test.ts diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 2cc29748c91..f991b7f8653 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -109,6 +109,8 @@ jobs: labels: ${{ steps.labels.outputs.value }} provenance: false push: true + cache-from: type=gha,scope=docker-release-amd64 + cache-to: type=gha,mode=max,scope=docker-release-amd64 - name: Build and push amd64 slim image id: build-slim @@ -122,6 +124,8 @@ jobs: labels: ${{ steps.labels.outputs.value }} provenance: false push: true + cache-from: type=gha,scope=docker-release-amd64 + cache-to: type=gha,mode=max,scope=docker-release-amd64 # Build arm64 images (default + slim share the build stage cache) build-arm64: @@ -210,6 +214,8 @@ jobs: labels: ${{ steps.labels.outputs.value }} provenance: false push: true + cache-from: type=gha,scope=docker-release-arm64 + cache-to: type=gha,mode=max,scope=docker-release-arm64 - name: Build and push arm64 slim image id: build-slim @@ -223,6 +229,8 @@ jobs: labels: ${{ steps.labels.outputs.value }} provenance: false push: true + cache-from: type=gha,scope=docker-release-arm64 + cache-to: type=gha,mode=max,scope=docker-release-arm64 # Create multi-platform manifests create-manifest: diff --git a/Dockerfile b/Dockerfile index f1d7163d192..d6923365b4b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,5 @@ +# syntax=docker/dockerfile:1.7 + # Opt-in extension dependencies at build time (space-separated directory names). # Example: docker build --build-arg OPENCLAW_EXTENSIONS="diagnostics-otel matrix" . # @@ -48,13 +50,13 @@ WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ COPY ui/package.json ./ui/package.json COPY patches ./patches -COPY scripts ./scripts COPY --from=ext-deps /out/ ./extensions/ # Reduce OOM risk on low-memory hosts during dependency installation. # Docker builds on small VMs may otherwise fail with "Killed" (exit 137). -RUN NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile +RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \ + NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile COPY . . @@ -117,11 +119,11 @@ WORKDIR /app # Install system utilities present in bookworm but missing in bookworm-slim. # On the full bookworm image these are already installed (apt-get is a no-op). -RUN apt-get update && \ +RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ + apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - procps hostname curl git openssl && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* + procps hostname curl git openssl RUN chown node:node /app @@ -145,11 +147,11 @@ RUN install -d -m 0755 "$COREPACK_HOME" && \ # Install additional system packages needed by your skills or extensions. # Example: docker build --build-arg OPENCLAW_DOCKER_APT_PACKAGES="python3 wget" . ARG OPENCLAW_DOCKER_APT_PACKAGES="" -RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \ +RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ + if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \ apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES; \ fi # Optionally install Chromium and Xvfb for browser automation. @@ -157,15 +159,15 @@ RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \ # Adds ~300MB but eliminates the 60-90s Playwright install on every container start. # Must run after node_modules COPY so playwright-core is available. ARG OPENCLAW_INSTALL_BROWSER="" -RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \ +RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ + if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \ apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends xvfb && \ mkdir -p /home/node/.cache/ms-playwright && \ PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright \ node /app/node_modules/playwright-core/cli.js install --with-deps chromium && \ - chown -R node:node /home/node/.cache/ms-playwright && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ + chown -R node:node /home/node/.cache/ms-playwright; \ fi # Optionally install Docker CLI for sandbox container management. @@ -174,7 +176,9 @@ RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \ # Required for agents.defaults.sandbox to function in Docker deployments. ARG OPENCLAW_INSTALL_DOCKER_CLI="" ARG OPENCLAW_DOCKER_GPG_FINGERPRINT="9DC858229FC7DD38854AE2D88D81803C0EBFCD88" -RUN if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \ +RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ + if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \ apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ ca-certificates curl gnupg && \ @@ -195,9 +199,7 @@ RUN if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \ "$(dpkg --print-architecture)" > /etc/apt/sources.list.d/docker.list && \ apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - docker-ce-cli docker-compose-plugin && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ + docker-ce-cli docker-compose-plugin; \ fi # Expose the CLI binary without requiring npm global writes as non-root. diff --git a/Dockerfile.sandbox b/Dockerfile.sandbox index a463d4a1020..8b50c7a6745 100644 --- a/Dockerfile.sandbox +++ b/Dockerfile.sandbox @@ -1,8 +1,12 @@ +# syntax=docker/dockerfile:1.7 + FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update \ +RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ + apt-get update \ && apt-get install -y --no-install-recommends \ bash \ ca-certificates \ @@ -10,8 +14,7 @@ RUN apt-get update \ git \ jq \ python3 \ - ripgrep \ - && rm -rf /var/lib/apt/lists/* + ripgrep RUN useradd --create-home --shell /bin/bash sandbox USER sandbox diff --git a/Dockerfile.sandbox-browser b/Dockerfile.sandbox-browser index 78b0de98904..f04e4a82a62 100644 --- a/Dockerfile.sandbox-browser +++ b/Dockerfile.sandbox-browser @@ -1,8 +1,12 @@ +# syntax=docker/dockerfile:1.7 + FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update \ +RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ + apt-get update \ && apt-get install -y --no-install-recommends \ bash \ ca-certificates \ @@ -17,8 +21,7 @@ RUN apt-get update \ socat \ websockify \ x11vnc \ - xvfb \ - && rm -rf /var/lib/apt/lists/* + xvfb COPY --chmod=755 scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser diff --git a/Dockerfile.sandbox-common b/Dockerfile.sandbox-common index 71f80070adf..39eaa3692b4 100644 --- a/Dockerfile.sandbox-common +++ b/Dockerfile.sandbox-common @@ -1,3 +1,5 @@ +# syntax=docker/dockerfile:1.7 + ARG BASE_IMAGE=openclaw-sandbox:bookworm-slim FROM ${BASE_IMAGE} @@ -19,9 +21,10 @@ ENV HOMEBREW_CELLAR=${BREW_INSTALL_DIR}/Cellar ENV HOMEBREW_REPOSITORY=${BREW_INSTALL_DIR}/Homebrew ENV PATH=${BUN_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/sbin:${PATH} -RUN apt-get update \ - && apt-get install -y --no-install-recommends ${PACKAGES} \ - && rm -rf /var/lib/apt/lists/* +RUN --mount=type=cache,id=openclaw-sandbox-common-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=openclaw-sandbox-common-apt-lists,target=/var/lib/apt,sharing=locked \ + apt-get update \ + && apt-get install -y --no-install-recommends ${PACKAGES} RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi @@ -42,4 +45,3 @@ fi # Default is sandbox, but allow BASE_IMAGE overrides to select another final user. USER ${FINAL_USER} - diff --git a/scripts/docker/cleanup-smoke/Dockerfile b/scripts/docker/cleanup-smoke/Dockerfile index 34ce3327ac7..e67a4b1fe87 100644 --- a/scripts/docker/cleanup-smoke/Dockerfile +++ b/scripts/docker/cleanup-smoke/Dockerfile @@ -1,15 +1,19 @@ +# syntax=docker/dockerfile:1.7 + FROM node:22-bookworm-slim@sha256:3cfe526ec8dd62013b8843e8e5d4877e297b886e5aace4a59fec25dc20736e45 -RUN apt-get update \ +RUN --mount=type=cache,id=openclaw-cleanup-smoke-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=openclaw-cleanup-smoke-apt-lists,target=/var/lib/apt,sharing=locked \ + apt-get update \ && apt-get install -y --no-install-recommends \ bash \ ca-certificates \ - git \ - && rm -rf /var/lib/apt/lists/* + git WORKDIR /repo COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -RUN corepack enable \ +RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \ + corepack enable \ && pnpm install --frozen-lockfile COPY . . diff --git a/scripts/docker/install-sh-e2e/Dockerfile b/scripts/docker/install-sh-e2e/Dockerfile index 839d637a04b..05b77f45197 100644 --- a/scripts/docker/install-sh-e2e/Dockerfile +++ b/scripts/docker/install-sh-e2e/Dockerfile @@ -1,12 +1,15 @@ +# syntax=docker/dockerfile:1.7 + FROM node:22-bookworm-slim@sha256:3cfe526ec8dd62013b8843e8e5d4877e297b886e5aace4a59fec25dc20736e45 -RUN apt-get update \ +RUN --mount=type=cache,id=openclaw-install-sh-e2e-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=openclaw-install-sh-e2e-apt-lists,target=/var/lib/apt,sharing=locked \ + apt-get update \ && apt-get install -y --no-install-recommends \ bash \ ca-certificates \ curl \ - git \ - && rm -rf /var/lib/apt/lists/* + git COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh COPY --chmod=755 run.sh /usr/local/bin/openclaw-install-e2e diff --git a/scripts/docker/install-sh-nonroot/Dockerfile b/scripts/docker/install-sh-nonroot/Dockerfile index 9b7912323a4..d0c085d9f69 100644 --- a/scripts/docker/install-sh-nonroot/Dockerfile +++ b/scripts/docker/install-sh-nonroot/Dockerfile @@ -1,6 +1,10 @@ +# syntax=docker/dockerfile:1.7 + FROM ubuntu:24.04@sha256:cd1dba651b3080c3686ecf4e3c4220f026b521fb76978881737d24f200828b2b -RUN set -eux; \ +RUN --mount=type=cache,id=openclaw-install-sh-nonroot-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=openclaw-install-sh-nonroot-apt-lists,target=/var/lib/apt,sharing=locked \ + set -eux; \ for attempt in 1 2 3; do \ if apt-get update -o Acquire::Retries=3; then break; fi; \ echo "apt-get update failed (attempt ${attempt})" >&2; \ @@ -14,8 +18,7 @@ RUN set -eux; \ g++ \ make \ python3 \ - sudo \ - && rm -rf /var/lib/apt/lists/* + sudo RUN useradd -m -s /bin/bash app \ && echo "app ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/app diff --git a/scripts/docker/install-sh-smoke/Dockerfile b/scripts/docker/install-sh-smoke/Dockerfile index eb2dcfe5226..94fdca13a31 100644 --- a/scripts/docker/install-sh-smoke/Dockerfile +++ b/scripts/docker/install-sh-smoke/Dockerfile @@ -1,6 +1,10 @@ +# syntax=docker/dockerfile:1.7 + FROM node:22-bookworm-slim@sha256:3cfe526ec8dd62013b8843e8e5d4877e297b886e5aace4a59fec25dc20736e45 -RUN set -eux; \ +RUN --mount=type=cache,id=openclaw-install-sh-smoke-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=openclaw-install-sh-smoke-apt-lists,target=/var/lib/apt,sharing=locked \ + set -eux; \ for attempt in 1 2 3; do \ if apt-get update -o Acquire::Retries=3; then break; fi; \ echo "apt-get update failed (attempt ${attempt})" >&2; \ @@ -15,8 +19,7 @@ RUN set -eux; \ g++ \ make \ python3 \ - sudo \ - && rm -rf /var/lib/apt/lists/* + sudo COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh diff --git a/scripts/e2e/Dockerfile b/scripts/e2e/Dockerfile index 9936acec8a7..e8bd039155d 100644 --- a/scripts/e2e/Dockerfile +++ b/scripts/e2e/Dockerfile @@ -1,3 +1,5 @@ +# syntax=docker/dockerfile:1.7 + FROM node:22-bookworm@sha256:cd7bcd2e7a1e6f72052feb023c7f6b722205d3fcab7bbcbd2d1bfdab10b1e935 RUN corepack enable @@ -6,20 +8,26 @@ WORKDIR /app ENV NODE_OPTIONS="--disable-warning=ExperimentalWarning" -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./ +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY ui/package.json ./ui/package.json +COPY extensions/memory-core/package.json ./extensions/memory-core/package.json +COPY patches ./patches + +RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \ + pnpm install --frozen-lockfile + +COPY tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./ COPY src ./src COPY test ./test COPY scripts ./scripts COPY docs ./docs COPY skills ./skills -COPY patches ./patches COPY ui ./ui COPY extensions/memory-core ./extensions/memory-core COPY vendor/a2ui/renderers/lit ./vendor/a2ui/renderers/lit COPY apps/shared/OpenClawKit/Sources/OpenClawKit/Resources ./apps/shared/OpenClawKit/Sources/OpenClawKit/Resources COPY apps/shared/OpenClawKit/Tools/CanvasA2UI ./apps/shared/OpenClawKit/Tools/CanvasA2UI -RUN pnpm install --frozen-lockfile RUN pnpm build RUN pnpm ui:build diff --git a/scripts/e2e/Dockerfile.qr-import b/scripts/e2e/Dockerfile.qr-import index f97d57891fd..e221e0278a9 100644 --- a/scripts/e2e/Dockerfile.qr-import +++ b/scripts/e2e/Dockerfile.qr-import @@ -1,12 +1,22 @@ +# syntax=docker/dockerfile:1.7 + FROM node:22-bookworm@sha256:cd7bcd2e7a1e6f72052feb023c7f6b722205d3fcab7bbcbd2d1bfdab10b1e935 RUN corepack enable WORKDIR /app -COPY . . +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY ui/package.json ./ui/package.json +COPY patches ./patches -RUN pnpm install --frozen-lockfile +# This image only exercises the root qrcode-terminal dependency path. +# Keep the pre-install copy set limited to the manifests needed for root +# workspace resolution so unrelated extension edits do not bust the layer. +RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \ + pnpm install --frozen-lockfile + +COPY . . RUN useradd --create-home --shell /bin/bash appuser \ && chown -R appuser:appuser /app diff --git a/scripts/sandbox-common-setup.sh b/scripts/sandbox-common-setup.sh index 95c90c8cb97..258ed19bcae 100755 --- a/scripts/sandbox-common-setup.sh +++ b/scripts/sandbox-common-setup.sh @@ -10,6 +10,9 @@ BUN_INSTALL_DIR="${BUN_INSTALL_DIR:-/opt/bun}" INSTALL_BREW="${INSTALL_BREW:-1}" BREW_INSTALL_DIR="${BREW_INSTALL_DIR:-/home/linuxbrew/.linuxbrew}" FINAL_USER="${FINAL_USER:-sandbox}" +OPENCLAW_DOCKER_BUILD_USE_BUILDX="${OPENCLAW_DOCKER_BUILD_USE_BUILDX:-0}" +OPENCLAW_DOCKER_BUILD_CACHE_FROM="${OPENCLAW_DOCKER_BUILD_CACHE_FROM:-}" +OPENCLAW_DOCKER_BUILD_CACHE_TO="${OPENCLAW_DOCKER_BUILD_CACHE_TO:-}" if ! docker image inspect "${BASE_IMAGE}" >/dev/null 2>&1; then echo "Base image missing: ${BASE_IMAGE}" @@ -19,7 +22,18 @@ fi echo "Building ${TARGET_IMAGE} with: ${PACKAGES}" -docker build \ +build_cmd=(docker build) +if [ "${OPENCLAW_DOCKER_BUILD_USE_BUILDX}" = "1" ]; then + build_cmd=(docker buildx build --load) + if [ -n "${OPENCLAW_DOCKER_BUILD_CACHE_FROM}" ]; then + build_cmd+=(--cache-from "${OPENCLAW_DOCKER_BUILD_CACHE_FROM}") + fi + if [ -n "${OPENCLAW_DOCKER_BUILD_CACHE_TO}" ]; then + build_cmd+=(--cache-to "${OPENCLAW_DOCKER_BUILD_CACHE_TO}") + fi +fi + +"${build_cmd[@]}" \ -t "${TARGET_IMAGE}" \ -f Dockerfile.sandbox-common \ --build-arg BASE_IMAGE="${BASE_IMAGE}" \ diff --git a/src/docker-build-cache.test.ts b/src/docker-build-cache.test.ts new file mode 100644 index 00000000000..6f56ef4f5c7 --- /dev/null +++ b/src/docker-build-cache.test.ts @@ -0,0 +1,127 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), ".."); + +async function readRepoFile(path: string): Promise { + return readFile(resolve(repoRoot, path), "utf8"); +} + +describe("docker build cache layout", () => { + it("keeps the root dependency layer independent from scripts changes", async () => { + const dockerfile = await readRepoFile("Dockerfile"); + const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile"); + const copyAllIndex = dockerfile.indexOf("COPY . ."); + const scriptsCopyIndex = dockerfile.indexOf("COPY scripts ./scripts"); + + expect(installIndex).toBeGreaterThan(-1); + expect(copyAllIndex).toBeGreaterThan(installIndex); + expect(scriptsCopyIndex === -1 || scriptsCopyIndex > installIndex).toBe(true); + }); + + it("uses pnpm cache mounts in Dockerfiles that install repo dependencies", async () => { + for (const path of [ + "Dockerfile", + "scripts/e2e/Dockerfile", + "scripts/e2e/Dockerfile.qr-import", + "scripts/docker/cleanup-smoke/Dockerfile", + ]) { + const dockerfile = await readRepoFile(path); + expect(dockerfile, `${path} should use a shared pnpm store cache`).toContain( + "--mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked", + ); + } + }); + + it("uses apt cache mounts in Dockerfiles that install system packages", async () => { + for (const path of [ + "Dockerfile", + "Dockerfile.sandbox", + "Dockerfile.sandbox-browser", + "Dockerfile.sandbox-common", + "scripts/docker/cleanup-smoke/Dockerfile", + "scripts/docker/install-sh-smoke/Dockerfile", + "scripts/docker/install-sh-e2e/Dockerfile", + "scripts/docker/install-sh-nonroot/Dockerfile", + ]) { + const dockerfile = await readRepoFile(path); + expect(dockerfile, `${path} should cache apt package archives`).toContain( + "target=/var/cache/apt,sharing=locked", + ); + expect(dockerfile, `${path} should cache apt metadata`).toContain( + "target=/var/lib/apt,sharing=locked", + ); + } + }); + + it("does not leave empty shell continuation lines in sandbox-common", async () => { + const dockerfile = await readRepoFile("Dockerfile.sandbox-common"); + expect(dockerfile).not.toContain("apt-get install -y --no-install-recommends ${PACKAGES} \\"); + expect(dockerfile).toContain( + 'RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi', + ); + }); + + it("does not leave blank lines after shell continuation markers", async () => { + for (const path of [ + "Dockerfile.sandbox", + "Dockerfile.sandbox-browser", + "Dockerfile.sandbox-common", + "scripts/docker/cleanup-smoke/Dockerfile", + "scripts/docker/install-sh-smoke/Dockerfile", + "scripts/docker/install-sh-e2e/Dockerfile", + "scripts/docker/install-sh-nonroot/Dockerfile", + ]) { + const dockerfile = await readRepoFile(path); + expect( + dockerfile, + `${path} should not have blank lines after a trailing backslash`, + ).not.toMatch(/\\\n\s*\n/); + } + }); + + it("copies only install inputs before pnpm install in the e2e image", async () => { + const dockerfile = await readRepoFile("scripts/e2e/Dockerfile"); + const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile"); + + expect( + dockerfile.indexOf("COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./"), + ).toBeLessThan(installIndex); + expect(dockerfile.indexOf("COPY ui/package.json ./ui/package.json")).toBeLessThan(installIndex); + expect( + dockerfile.indexOf( + "COPY extensions/memory-core/package.json ./extensions/memory-core/package.json", + ), + ).toBeLessThan(installIndex); + expect( + dockerfile.indexOf( + "COPY tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./", + ), + ).toBeGreaterThan(installIndex); + expect(dockerfile.indexOf("COPY src ./src")).toBeGreaterThan(installIndex); + expect(dockerfile.indexOf("COPY test ./test")).toBeGreaterThan(installIndex); + expect(dockerfile.indexOf("COPY scripts ./scripts")).toBeGreaterThan(installIndex); + expect(dockerfile.indexOf("COPY ui ./ui")).toBeGreaterThan(installIndex); + }); + + it("copies manifests before install in the qr-import image", async () => { + const dockerfile = await readRepoFile("scripts/e2e/Dockerfile.qr-import"); + const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile"); + + expect( + dockerfile.indexOf("COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./"), + ).toBeLessThan(installIndex); + expect(dockerfile.indexOf("COPY ui/package.json ./ui/package.json")).toBeLessThan(installIndex); + expect(dockerfile).toContain( + "This image only exercises the root qrcode-terminal dependency path.", + ); + expect( + dockerfile.indexOf( + "COPY extensions/memory-core/package.json ./extensions/memory-core/package.json", + ), + ).toBe(-1); + expect(dockerfile.indexOf("COPY . .")).toBeGreaterThan(installIndex); + }); +});