fix(server): stamp the real build version into images instead of the package.json placeholder (#10257)

## 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.<sha>`), 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<sha>`, which `resolveServerVersion()` reports as
`2026.722.0+51.git.<sha>` — 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 <noreply@paperclip.ing>
This commit is contained in:
Devin Foley 2026-07-25 10:06:28 -07:00 committed by GitHub
parent 4c55f0d8da
commit d1b9448b57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 209 additions and 0 deletions

View File

@ -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:-<none>}"
- 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

View File

@ -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 \

View File

@ -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();
});
});

View File

@ -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();

View File

@ -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;
}
}

View File

@ -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()