From 3d3cc4941aed85db30c456cc3925b684e944c6c3 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 10 Sep 2026 18:35:54 -0700 Subject: [PATCH 1/2] ci: preserve weekly Docker tool cache across commits Co-Authored-By: Paperclip --- Dockerfile | 15 +++++++-------- doc/DOCKER.md | 8 ++++++++ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index b51a2cfa97..901289778e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -103,14 +103,6 @@ RUN rm -rf packages/paperclip-runner/runner/target FROM base AS production ARG USER_UID=1000 ARG USER_GID=1000 -# Real version for this build, computed from `git describe` on the CI runner -# (the image has no .git, so the server cannot derive it at runtime). Empty for -# local `docker build`, which just leaves the server on its normal fallbacks. -ARG PAPERCLIP_BUILD_VERSION="" -# The exact commit this image was built from, for the same reason: server-info -# falls back to PAPERCLIP_BUILD_COMMIT when git is unavailable, which feeds the -# /api/health `commit` field that deploy tooling verifies. Empty locally. -ARG PAPERCLIP_BUILD_COMMIT="" # Refreshes the tool layer below when it changes (CI stamps an ISO week, so # the @latest CLI tools advance weekly). Without it the cached layer would # freeze the tools until an unrelated cache bust. @@ -133,6 +125,13 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh COPY --chown=node:node --from=build /app /app +# Declare per-build metadata after the stable RUN layers. Docker includes +# in-scope ARG values in a RUN's environment even when its command does not +# mention them; declaring these earlier invalidates the weekly tool cache. +# The build stage still receives the commit before writing dist/build-info.json. +# Empty for local builds, preserving the server's normal version fallbacks. +ARG PAPERCLIP_BUILD_VERSION="" +ARG PAPERCLIP_BUILD_COMMIT="" ENV NODE_ENV=production \ HOME=/paperclip \ HOST=0.0.0.0 \ diff --git a/doc/DOCKER.md b/doc/DOCKER.md index 01278a8343..20b80eeb72 100644 --- a/doc/DOCKER.md +++ b/doc/DOCKER.md @@ -18,6 +18,14 @@ Build arguments: |-----|---------|---------| | `USER_UID` | `1000` | UID for the container `node` user (match your host UID to avoid permission issues on bind mounts) | | `USER_GID` | `1000` | GID for the container `node` group | +| `CLI_TOOLS_CACHE_EPOCH` | empty | Refresh the CLI-install layer; CI supplies the current ISO week | +| `PAPERCLIP_BUILD_VERSION` | empty | Runtime version when Git metadata is unavailable | +| `PAPERCLIP_BUILD_COMMIT` | empty | Source commit written into the server build stamp and runtime environment | + +Changing the build version or commit preserves the CLI-install cache. The +tool layer refreshes when its weekly epoch, base image, installation command, +or earlier build inputs change. Local builds can set a new epoch explicitly +to refresh tools without clearing the entire build cache. ```sh docker build -t paperclip-local \ From 672da3f0796f4e8c192b680c6515be149d387ed8 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 10 Sep 2026 18:45:43 -0700 Subject: [PATCH 2/2] test: guard production metadata cache ordering Co-Authored-By: Paperclip --- .../src/__tests__/docker-build-stamp.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/server/src/__tests__/docker-build-stamp.test.ts b/server/src/__tests__/docker-build-stamp.test.ts index f013d30ec6..454b8b912e 100644 --- a/server/src/__tests__/docker-build-stamp.test.ts +++ b/server/src/__tests__/docker-build-stamp.test.ts @@ -35,6 +35,25 @@ function stageBody(source: string, stageName: string): string { return source.slice(start, end); } +it("keeps per-build runtime metadata out of the weekly CLI-install cache", () => { + const production = stageBody(dockerfile, "production"); + const tools = production.search(/^RUN echo "cli-tools-epoch:/m); + const entrypoint = production.search(/^RUN chmod \+x \/usr\/local\/bin\/docker-entrypoint\.sh/m); + const runtime = production.search(/^ENV NODE_ENV=production/m); + const epoch = production.search(/^ARG CLI_TOOLS_CACHE_EPOCH\b/m); + expect(tools).toBeGreaterThanOrEqual(0); + expect(entrypoint).toBeGreaterThan(tools); + expect(epoch).toBeGreaterThanOrEqual(0); + expect(epoch).toBeLessThan(tools); + for (const name of ["PAPERCLIP_BUILD_VERSION", "PAPERCLIP_BUILD_COMMIT"]) { + const declarations = [...production.matchAll(new RegExp(`^ARG ${name}\\b`, "gm"))]; + expect(declarations).toHaveLength(1); + expect(declarations[0].index).toBeGreaterThan(entrypoint); + expect(declarations[0].index).toBeLessThan(runtime); + expect(production.slice(runtime)).toContain(`${name}=\${${name}}`); + } +}); + describe("docker build-stamp wiring", () => { it("declares PAPERCLIP_BUILD_COMMIT in the build stage before the server build", () => { const build = stageBody(dockerfile, "build");