From d1b9448b57a8cfb0e8dbede9bbbc8874a9f66ad7 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sat, 25 Jul 2026 10:06:28 -0700 Subject: [PATCH] fix(server): stamp the real build version into images instead of the package.json placeholder (#10257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work; it ships as a Docker image that self-hosters and managed deployments run. > - The server resolves its own version at runtime in `server/src/version.ts` (`resolveServerVersion()`), which feeds analytics and the server debug panel. > - That resolver derives the real version from `git describe`, and falls back to `server/package.json`'s `version` when git isn't available. > - But `server/package.json`'s version is a static placeholder — CI only stamps the real CalVer at publish, so in source it is never the real version (currently `0.3.1`). > - A Docker image has no `.git` (it's dockerignored), so `git describe` can't run inside it. Every image therefore falls back to the placeholder and reports `0.3.1` in analytics and the debug panel, regardless of which commit it was built from. > - This PR computes the real version once on the CI build runner (where `.git` and tags exist), bakes it into the image, and has `resolveServerVersion()` prefer that stamp when `git describe` is unavailable. > - The benefit: self-hosted and cloud images report their true version instead of a misleading placeholder, with no change to dev checkouts, `git describe`-based resolution, or local `docker build`. ## Linked Issues or Issue Description No public issue exists — describing the bug inline (per the bug report template). **What happened?** Docker images built from `master` (and release tags) report the server version as the `0.3.1` placeholder in analytics and the server debug panel, instead of the real version of the commit the image was built from. **Expected behavior** An image reports the real version of its build commit (e.g. `2026.722.0+51.git.`), so operators can tell which build is running. **Steps to reproduce** 1. Build the server Docker image from any `master` commit (the `Docker` workflow, `production` target). 2. Run the image and open the server debug panel (or inspect the version reported to analytics). 3. Observe the version is `0.3.1` rather than the commit's real version. **Root cause** `resolveServerVersion()` derives the real version from `git describe`, but the image has no `.git` (dockerignored), so it falls back to `server/package.json`'s `version` — a static placeholder CI only replaces with the real CalVer at publish time. Nothing bakes the real version into the image. **Paperclip version or commit:** reproduces on `master` (`4c55f0d8`) and any published image. **Deployment mode:** self-hosted and managed (both the `production` and `-cloud` images). **Installation method:** Docker image (`ghcr.io/paperclipai/paperclip`). **Related PRs (dedup search):** #9103 (merged — added the `git describe`-based source-install resolution this builds on) and #9637 (closed). Neither bakes a version into the image; this PR closes that gap. No duplicate found. ## What Changed - **`.github/workflows/docker.yml`** — checkout with full history + tags (`fetch-depth: 0`), and a new `Compute build version` step that runs `git describe --tags --match 'v*' --long --dirty` on the pristine runner checkout. The result is passed as a `PAPERCLIP_BUILD_VERSION` build-arg to both the `production` and `-cloud` image builds. - **`Dockerfile`** — the `production` stage takes an `ARG PAPERCLIP_BUILD_VERSION` (default empty) and bakes it into the runtime `ENV`; the `cloud` stage inherits it via `FROM production`. - **`server/src/build-version.ts`** (new) — `readBuildVersion()` / `parseBuildVersion()`, mirroring `build-commit.ts`: reads `PAPERCLIP_BUILD_VERSION` (or a `.paperclip-build-version` file) as a single-token stamp. - **`server/src/version.ts`** — `resolveServerVersion()` prefers the baked build version when `git describe` is unavailable, parsing it with the same rules as a live checkout (`parseGitDescribeVersion`), and falling through to the existing `build-commit` stamp and package version when unset. A live checkout's `git describe` still wins over any stamp. - Tests for the new behavior and the precedence. ## Verification - `pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit` in `server/` — clean. - `vitest run server/src/__tests__/version.test.ts server/src/__tests__/build-version.test.ts` — **23 tests pass**, covering: stamped version used when git describe fails, stamp parsed to real CalVer, stamp preferred over the build-commit fallback, on-tag stamp collapses to the release version, a pre-resolved stamp used verbatim, and a live git describe still winning over a stamp. - `git describe --tags --match 'v*' --long` for this commit → `v2026.722.0-51-g`, which `resolveServerVersion()` reports as `2026.722.0+51.git.` — no longer `0.3.1`. - Not run locally: the full multi-arch image build (CI-only). The workflow change is verified by inspection; the version is computed on the pristine checkout before any lockfile refresh, so it carries no spurious `-dirty`. ## Risks Low. Additive and image-only: - No runtime behavior changes for dev checkouts (git describe still primary and wins over any stamp) or for local `docker build` (empty arg → server keeps its existing fallbacks). - Not a breaking change; no schema or API surface. The stamp is informational (version reporting only). - `fetch-depth: 0` makes the release-image checkout fetch full history/tags — a modest cost on a workflow that already runs at release cadence with a 60-minute budget. - Rollback: revert the commit; images simply return to reporting the placeholder. ## Model Used Claude Opus 4.8 (`claude-opus-4-8`, 1M-context variant), extended thinking, with tool use / code execution — agentic edits, `tsc` + `vitest` runs, and a `git describe` resolution check. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work (bugfix, not core feature work) - [x] I have searched GitHub for duplicate or related PRs and linked them above (#9103, #9637 — related, not duplicates) - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (`fix/build-version-stamp`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (no user-facing docs affected; behavior is documented inline in `version.ts` / `build-version.ts` and the workflow/Dockerfile) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- .github/workflows/docker.yml | 21 ++++++++ Dockerfile | 5 ++ server/src/__tests__/build-version.test.ts | 58 ++++++++++++++++++++ server/src/__tests__/version.test.ts | 61 ++++++++++++++++++++++ server/src/build-version.ts | 45 ++++++++++++++++ server/src/version.ts | 19 +++++++ 6 files changed, 209 insertions(+) create mode 100644 server/src/__tests__/build-version.test.ts create mode 100644 server/src/build-version.ts diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 1cf0640c62..66c505e3f8 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -21,6 +21,24 @@ jobs: steps: - name: Checkout uses: actions/checkout@v7 + with: + # Full history and tags so `git describe` below can compute the + # release version to stamp into the image. + fetch-depth: 0 + + # `.git` is dockerignored, so a running image cannot derive its own + # version and otherwise reports the source package.json placeholder in + # analytics and the debug panel. Compute it here from the pristine + # checkout (real CalVer drift from the nearest release tag) and pass it + # into both builds. Empty when no release tag is reachable — the server + # then keeps its existing fallbacks. + - name: Compute build version + id: build-version + run: | + set -euo pipefail + version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)" + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "Stamping build version: ${version:-}" - name: Setup pnpm uses: pnpm/action-setup@v6 @@ -124,6 +142,8 @@ jobs: # the Dockerfile now declares a later `cloud` stage, and without a # target the default would silently become that stage. target: production + build-args: | + PAPERCLIP_BUILD_VERSION=${{ steps.build-version.outputs.version }} platforms: linux/amd64,linux/arm64 push: true cache-from: type=gha @@ -161,6 +181,7 @@ jobs: # the variant; add here when managed deployments need another. build-args: | CLOUD_BUNDLED_PLUGINS=daytona + PAPERCLIP_BUILD_VERSION=${{ steps.build-version.outputs.version }} platforms: linux/amd64,linux/arm64 push: true cache-from: type=gha diff --git a/Dockerfile b/Dockerfile index e6a3cba9d1..7793c247fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,6 +59,10 @@ RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" & 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="" WORKDIR /app COPY --chown=node:node --from=build /app /app RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai @google/gemini-cli@latest \ @@ -78,6 +82,7 @@ ENV NODE_ENV=production \ SERVE_UI=true \ PAPERCLIP_HOME=/paperclip \ PAPERCLIP_INSTANCE_ID=default \ + PAPERCLIP_BUILD_VERSION=${PAPERCLIP_BUILD_VERSION} \ USER_UID=${USER_UID} \ USER_GID=${USER_GID} \ PAPERCLIP_CONFIG=/paperclip/instances/default/config.json \ diff --git a/server/src/__tests__/build-version.test.ts b/server/src/__tests__/build-version.test.ts new file mode 100644 index 0000000000..9bf122583c --- /dev/null +++ b/server/src/__tests__/build-version.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest"; +import { parseBuildVersion, readBuildVersion } from "../build-version.js"; + +describe("parseBuildVersion", () => { + it("trims a stamped git describe string", () => { + expect(parseBuildVersion(" v2026.722.0-15-g4c55f0d\n")).toBe("v2026.722.0-15-g4c55f0d"); + }); + + it("accepts an already-resolved version verbatim", () => { + expect(parseBuildVersion("2026.725.0-canary.2")).toBe("2026.725.0-canary.2"); + }); + + it("rejects empty and whitespace-bearing values", () => { + expect(parseBuildVersion("")).toBeNull(); + expect(parseBuildVersion(" ")).toBeNull(); + expect(parseBuildVersion("v1 with spaces")).toBeNull(); + expect(parseBuildVersion(null)).toBeNull(); + expect(parseBuildVersion(undefined)).toBeNull(); + }); +}); + +describe("readBuildVersion", () => { + it("prefers an explicit environment version over the file", () => { + const readTextFile = vi.fn(() => "v9999.0.0-0-g0000000"); + + expect( + readBuildVersion({ + environmentVersion: "v2026.722.0-15-g4c55f0d", + readTextFile, + }), + ).toBe("v2026.722.0-15-g4c55f0d"); + expect(readTextFile).not.toHaveBeenCalled(); + }); + + it("reads the build marker when no environment version is set", () => { + expect( + readBuildVersion({ + environmentVersion: null, + buildVersionPath: "/app/.paperclip-build-version", + readTextFile: (path) => { + expect(path).toBe("/app/.paperclip-build-version"); + return "v2026.722.0-15-g4c55f0d\n"; + }, + }), + ).toBe("v2026.722.0-15-g4c55f0d"); + }); + + it("returns null when neither the environment nor the file provides a version", () => { + expect( + readBuildVersion({ + environmentVersion: null, + readTextFile: () => { + throw new Error("ENOENT"); + }, + }), + ).toBeNull(); + }); +}); diff --git a/server/src/__tests__/version.test.ts b/server/src/__tests__/version.test.ts index d6d717d8e6..c3e05f95e8 100644 --- a/server/src/__tests__/version.test.ts +++ b/server/src/__tests__/version.test.ts @@ -101,6 +101,7 @@ describe("resolveServerVersion", () => { it("uses deployment commit metadata when a source build has no git directory", () => { expect( resolveServerVersion({ + buildVersion: null, buildCommit: "0123456789abcdef0123456789abcdef01234567", packageVersion: "2026.706.0", gitDescribeCommand: () => { @@ -111,6 +112,66 @@ describe("resolveServerVersion", () => { ).toBe("2026.706.0+0.git.0123456"); }); + it("uses the stamped build version when a Docker image has no git directory", () => { + const debugLog = vi.fn(); + + expect( + resolveServerVersion({ + // A real CalVer describe stamped by CI wins over the coarse build-commit + // stamp and the source placeholder — this is the analytics/debug-panel fix. + buildVersion: "v2026.722.0-15-g4c55f0d", + buildCommit: "0123456789abcdef0123456789abcdef01234567", + packageVersion: "0.3.1", + gitDescribeCommand: () => { + throw new Error("fatal: not a git repository"); + }, + debugLog, + }), + ).toBe("2026.722.0+15.git.4c55f0d"); + expect(debugLog).toHaveBeenCalledWith( + { reason: "build_version" }, + "using stamped build version for server version", + ); + }); + + it("collapses an on-tag stamped build version to the release version", () => { + expect( + resolveServerVersion({ + buildVersion: "v2026.722.0-0-g4c55f0d", + packageVersion: "0.3.1", + gitDescribeCommand: () => { + throw new Error("no git"); + }, + debugLog: vi.fn(), + }), + ).toBe("2026.722.0"); + }); + + it("uses a pre-resolved stamped build version verbatim", () => { + expect( + resolveServerVersion({ + buildVersion: "2026.725.0-canary.2", + packageVersion: "0.3.1", + gitDescribeCommand: () => { + throw new Error("no git"); + }, + debugLog: vi.fn(), + }), + ).toBe("2026.725.0-canary.2"); + }); + + it("keeps the live git-derived version even when a build version is stamped", () => { + expect( + resolveServerVersion({ + // A stamped version is only a fallback: a real checkout's git describe wins. + buildVersion: "v2020.1.1-0-g0000000", + packageVersion: "0.3.1", + gitDescribeCommand: () => "v2026.626.0-58-g518fc71ce\n", + debugLog: vi.fn(), + }), + ).toBe("2026.626.0+58.git.518fc71ce"); + }); + it("skips git metadata probing for packaged installs under node_modules", () => { const debugLog = vi.fn(); diff --git a/server/src/build-version.ts b/server/src/build-version.ts new file mode 100644 index 0000000000..f0938e739c --- /dev/null +++ b/server/src/build-version.ts @@ -0,0 +1,45 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +type ReadTextFile = (path: string) => string; + +// The build version stamp is computed by CI on the build runner (where `.git` +// exists) and baked into the image, so a running container reports the real +// version instead of the source `package.json` placeholder. It is typically the +// raw `git describe` output, e.g. "v2026.722.0-15-g4c55f0d"; version.ts runs it +// through the same parser used for a live checkout. A resolved version set +// directly is accepted verbatim. +const DEFAULT_BUILD_VERSION_PATH = fileURLToPath( + new URL("../../.paperclip-build-version", import.meta.url), +); + +export function parseBuildVersion(value: string | null | undefined): string | null { + const version = value?.trim() ?? ""; + // A version/describe string is a single token: reject empties and anything + // carrying whitespace so a stray file cannot inject a multi-line value. + if (!version || /\s/.test(version)) return null; + return version; +} + +export function readBuildVersion( + opts: { + environmentVersion?: string | null; + buildVersionPath?: string; + readTextFile?: ReadTextFile; + } = {}, +): string | null { + const environmentVersion = parseBuildVersion( + opts.environmentVersion === undefined + ? process.env.PAPERCLIP_BUILD_VERSION + : opts.environmentVersion, + ); + if (environmentVersion) return environmentVersion; + + try { + const readTextFile = + opts.readTextFile ?? ((path: string) => readFileSync(path, "utf8")); + return parseBuildVersion(readTextFile(opts.buildVersionPath ?? DEFAULT_BUILD_VERSION_PATH)); + } catch { + return null; + } +} diff --git a/server/src/version.ts b/server/src/version.ts index 525c2e2697..6627637b02 100644 --- a/server/src/version.ts +++ b/server/src/version.ts @@ -3,6 +3,7 @@ import { execFileSync } from "node:child_process"; import { existsSync, realpathSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { parseBuildCommit, readBuildCommit } from "./build-commit.js"; +import { parseBuildVersion, readBuildVersion } from "./build-version.js"; type PackageJson = { version?: string; @@ -149,6 +150,7 @@ export function parseGitDescribeVersion(output: string): string | null { export function resolveServerVersion( opts: { buildCommit?: string | null; + buildVersion?: string | null; gitDescribeCommand?: GitDescribeCommand; packageVersion?: string; debugLog?: DebugLog; @@ -191,6 +193,23 @@ export function resolveServerVersion( ); } + // Prefer a version stamped into the build. A Docker image has no `.git`, so + // the git describe above cannot run; CI computes the version on the build + // runner and bakes it in, carrying the real CalVer instead of the source + // placeholder. Parsed with the same rules as a live checkout, so both report + // the same string. Falls through to the coarser build-commit stamp when unset. + const buildVersion = + opts.buildVersion === undefined + ? readBuildVersion() + : parseBuildVersion(opts.buildVersion); + if (buildVersion) { + debugLog( + { reason: "build_version" }, + "using stamped build version for server version", + ); + return parseGitDescribeVersion(buildVersion) ?? buildVersion; + } + const buildCommit = opts.buildCommit === undefined ? readBuildCommit()