Files
PaperClipAI/server/src/version.ts
T
Devin FoleyandPaperclip d1b9448b57 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>
2026-07-25 10:06:28 -07:00

225 lines
6.5 KiB
TypeScript

import { createRequire } from "node:module";
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;
};
type GitDescribeCommand = () => string;
type DebugLog = (fields: Record<string, unknown>, message: string) => void;
type PathExists = (path: string) => boolean;
type Realpath = (path: string) => string;
const requirePackage = createRequire(import.meta.url);
const packageRoot = dirname(requirePackage.resolve("../package.json"));
const pkg = requirePackage("../package.json") as PackageJson;
const GIT_DESCRIBE_RE =
/^v(?<publicVersion>\d+\.\d+\.\d+)-(?<commitsSinceTag>\d+)-g(?<sha>[0-9a-f]{7,40})(?<dirty>-dirty)?$/i;
function defaultDebugLog(fields: Record<string, unknown>, message: string): void {
if (process.env.PAPERCLIP_DEBUG_VERSION_RESOLUTION !== "1") return;
console.debug(message, fields);
}
function defaultGitDescribeCommand(): string {
return execFileSync(
"git",
["describe", "--tags", "--match", "v*", "--long", "--dirty"],
{
cwd: packageRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 1500,
},
);
}
function hasPathSegment(path: string, segment: string): boolean {
return path.split(/[\\/]+/).includes(segment);
}
function safeRealpath(path: string, realpath: Realpath): string {
try {
return realpath(path);
} catch {
return path;
}
}
function hasGitMetadataBeforeNodeModulesBoundary(
path: string,
pathExists: PathExists,
): boolean {
let current = path;
while (true) {
if (pathExists(join(current, ".git"))) return true;
const parent = dirname(current);
if (parent === current || basename(current) === "node_modules") return false;
current = parent;
}
}
function isPackagedInstall(
path: string,
{
pathExists = existsSync,
realpath = realpathSync,
}: { pathExists?: PathExists; realpath?: Realpath } = {},
): boolean {
const realPackageRoot = safeRealpath(path, realpath);
const candidateRoots = Array.from(new Set([path, realPackageRoot]));
const hasNodeModulesSegment = candidateRoots.some((candidate) =>
hasPathSegment(candidate, "node_modules"),
);
if (!hasNodeModulesSegment) return false;
return !candidateRoots.some((candidate) =>
hasGitMetadataBeforeNodeModulesBoundary(candidate, pathExists),
);
}
function normalizeErrorField(value: unknown): unknown {
if (Buffer.isBuffer(value)) return value.toString("utf8");
if (value instanceof Uint8Array) return Buffer.from(value).toString("utf8");
return value;
}
function compactRecord(fields: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(fields).filter(([, value]) => value !== undefined),
);
}
function summarizeError(err: unknown): Record<string, unknown> {
if (err && typeof err === "object") {
const errorLike = err as {
name?: unknown;
message?: unknown;
status?: unknown;
signal?: unknown;
code?: unknown;
stdout?: unknown;
stderr?: unknown;
stack?: unknown;
cause?: unknown;
};
return compactRecord({
name: errorLike.name,
message: errorLike.message,
status: errorLike.status,
signal: errorLike.signal,
code: errorLike.code,
stdout: normalizeErrorField(errorLike.stdout),
stderr: normalizeErrorField(errorLike.stderr),
stack: errorLike.stack,
cause:
errorLike.cause === undefined ? undefined : summarizeError(errorLike.cause),
});
}
return { message: String(err) };
}
export function parseGitDescribeVersion(output: string): string | null {
const match = output.trim().match(GIT_DESCRIBE_RE);
if (!match?.groups) return null;
const publicVersion = match.groups.publicVersion;
const commitsSinceTag = match.groups.commitsSinceTag;
const sha = match.groups.sha;
const isDirty = Boolean(match.groups.dirty);
if (commitsSinceTag === "0" && !isDirty) {
return publicVersion;
}
return `${publicVersion}+${commitsSinceTag}.git.${sha}${isDirty ? ".dirty" : ""}`;
}
export function resolveServerVersion(
opts: {
buildCommit?: string | null;
buildVersion?: string | null;
gitDescribeCommand?: GitDescribeCommand;
packageVersion?: string;
debugLog?: DebugLog;
packageRoot?: string;
pathExists?: PathExists;
realpath?: Realpath;
} = {},
): string {
const packageVersion = opts.packageVersion ?? pkg.version ?? "0.0.0";
const gitDescribeCommand = opts.gitDescribeCommand ?? defaultGitDescribeCommand;
const debugLog = opts.debugLog ?? defaultDebugLog;
const resolvedPackageRoot = opts.packageRoot ?? packageRoot;
if (
isPackagedInstall(resolvedPackageRoot, {
pathExists: opts.pathExists,
realpath: opts.realpath,
})
) {
debugLog(
{ reason: "packaged_install" },
"falling back to package version for server version",
);
return packageVersion;
}
try {
const parsedVersion = parseGitDescribeVersion(gitDescribeCommand());
if (parsedVersion) return parsedVersion;
debugLog(
{ reason: "invalid_git_describe" },
"falling back to package version for server version",
);
return packageVersion;
} catch (err) {
debugLog(
{ err: summarizeError(err), reason: "git_describe_unavailable" },
"falling back to package version for server version",
);
}
// 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()
: parseBuildCommit(opts.buildCommit);
if (buildCommit) {
return `${packageVersion}+0.git.${buildCommit.slice(0, 7)}`;
}
return packageVersion;
}
export const serverVersion = resolveServerVersion();