From 8516700217537a78dc720c0725591f50ed87414c Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Mon, 6 Jul 2026 09:15:44 -0700 Subject: [PATCH] fix(server): report source-install version from git metadata (#9103) 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 > - The server reports its own version through `server/src/version.ts`, which is read by the `/health` endpoint and the telemetry client > - When running from a cloned source tree, `server/package.json` is frozen at the last published release version (e.g. `0.3.1`), so the reported `serverVersion` never reflects how far the local checkout has drifted from that release > - Operators and support staff cannot tell from telemetry or health output whether they are running a tagged release or a development build with local commits on top > - A `git describe --tags --match v* --long --dirty` call at startup gives the exact nearest tag, number of commits since it, the current SHA, and whether the tree is dirty — all the information needed to compute a semantically meaningful version > - This pull request replaces the static `pkg.version` export with a `resolveServerVersion()` call that parses `git describe` output into `YYYY.MDD.P+N.git.` (drift), `YYYY.MDD.P` (clean on-tag), or appends `.dirty` for a modified tree, with a non-throwing fallback to `package.json` when git is unavailable > - The benefit is that from-source installs now report a version string that lets operators and support quickly identify their exact checkout state without running additional git commands ## Linked Issues or Issue Description No pre-existing public issue. Inline description: **What happened?** When Paperclip is installed from source (git clone + pnpm), `GET /health` and the telemetry envelope report the version frozen at the last published `package.json` value (e.g. `0.3.1`) regardless of how many commits ahead of that tag the local checkout is. **Expected behavior** The reported version should reflect the actual local state — nearest release tag, number of commits since that tag, abbreviated commit SHA, and a dirty marker when the working tree has uncommitted changes. **Steps to reproduce** Clone the repo, run `pnpm install && pnpm --filter @paperclipai/server start`, then call `GET /health` or inspect telemetry envelopes. The `serverVersion` field shows the `package.json` version even when the checkout is dozens of commits ahead of that tag. **Paperclip version or commit** Affects all source-tree installs where `package.json` has not been updated to match the current HEAD. **Deployment mode** Source install (git clone). ## What Changed - `server/src/version.ts`: extracted `resolveServerVersion()` (replaces the module-level `const serverVersion`) and `parseGitDescribeVersion()` (exported for unit testing); the default implementation shells out to `git describe --tags --match v* --long --dirty` with a 1 500 ms timeout; falls back to `pkg.version ?? "0.0.0"` without throwing when git is unavailable or the output cannot be parsed; replaced `logger` import with a `console.debug`-based default to avoid pulling pino transport side effects into a zero-dependency utility module - `server/src/__tests__/version.test.ts`: 7-test unit suite covering drift, clean on-tag collapse, dirty on-tag edge case, unparseable fallback, `resolveServerVersion` happy path, and git-unavailable fallback — all exercised via injected stubs without spawning a real git process ## Verification ```sh # Unit tests (7 tests) pnpm exec vitest run server/src/__tests__/version.test.ts # Type check pnpm --filter @paperclipai/server typecheck # Health and telemetry regression pnpm exec vitest run server/src/__tests__/health.test.ts server/src/__tests__/telemetry-client-flush.test.ts # Runtime smoke (from-source checkout) # git describe --tags --match 'v*' --long => v2026.626.0-58-g518fc71ce # server startup => serverVersion = 2026.626.0+59.git.3367571cc ``` All commands passed at the committed HEAD. ## Risks Low. The change is additive and self-contained to `server/src/version.ts`: - `git describe` is called once at module load with a 1 500 ms timeout; failure (non-git environment, git not on PATH, timeout) is silently caught and falls back to `pkg.version`, preserving existing behavior for published-package installs - No API surface, database schema, or migration is touched - The telemetry envelope already carried `serverVersion`; only the value changes for source-tree installs ## Model Used Claude Sonnet 4.6 (`claude-sonnet-4-6`) with tool use and code execution. Context window: 200 k tokens. ## 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 - [x] I have searched GitHub for duplicate or related PRs and linked them above - [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) - [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`) 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 - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- server/src/__tests__/version.test.ts | 60 ++++++++++++++++++++++ server/src/version.ts | 74 ++++++++++++++++++++++++++-- 2 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 server/src/__tests__/version.test.ts diff --git a/server/src/__tests__/version.test.ts b/server/src/__tests__/version.test.ts new file mode 100644 index 0000000000..9d8f9d8b1f --- /dev/null +++ b/server/src/__tests__/version.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; +import { parseGitDescribeVersion, resolveServerVersion } from "../version.js"; + +describe("parseGitDescribeVersion", () => { + it("reports drift from the nearest release tag as a PEP 440 local version", () => { + expect(parseGitDescribeVersion("v2026.626.0-58-g518fc71ce\n")).toBe( + "2026.626.0+58.git.518fc71ce", + ); + }); + + it("collapses clean on-tag checkouts to the release version", () => { + expect(parseGitDescribeVersion("v2026.626.0-0-g012345678\n")).toBe("2026.626.0"); + }); + + it("adds dirty state to the local version segment", () => { + expect(parseGitDescribeVersion("v2026.626.0-58-g518fc71ce-dirty\n")).toBe( + "2026.626.0+58.git.518fc71ce.dirty", + ); + }); + + it("returns null for unparseable describe output so callers can fall back", () => { + expect(parseGitDescribeVersion("canary/v2026.706.0-canary.1")).toBeNull(); + }); + + it("appends dirty suffix even when on-tag (zero commits since tag)", () => { + expect(parseGitDescribeVersion("v2026.626.0-0-g012345678-dirty\n")).toBe( + "2026.626.0+0.git.012345678.dirty", + ); + }); +}); + +describe("resolveServerVersion", () => { + it("returns the parsed git-derived version when git describe succeeds", () => { + expect( + resolveServerVersion({ + packageVersion: "2026.706.0", + gitDescribeCommand: () => "v2026.626.0-58-g518fc71ce\n", + debugLog: vi.fn(), + }), + ).toBe("2026.626.0+58.git.518fc71ce"); + }); + + it("falls back to package version without throwing when git is unavailable", () => { + const debugLog = vi.fn(); + + expect( + resolveServerVersion({ + packageVersion: "2026.706.0", + gitDescribeCommand: () => { + throw new Error("fatal: not a git repository"); + }, + debugLog, + }), + ).toBe("2026.706.0"); + expect(debugLog).toHaveBeenCalledWith( + expect.objectContaining({ reason: "git_describe_unavailable" }), + "falling back to package version for server version", + ); + }); +}); diff --git a/server/src/version.ts b/server/src/version.ts index 39a16a4cfe..1dfff0aa1a 100644 --- a/server/src/version.ts +++ b/server/src/version.ts @@ -1,10 +1,78 @@ import { createRequire } from "node:module"; +import { execFileSync } from "node:child_process"; type PackageJson = { version?: string; }; -const require = createRequire(import.meta.url); -const pkg = require("../package.json") as PackageJson; +type GitDescribeCommand = () => string; +type DebugLog = (fields: Record, message: string) => void; -export const serverVersion = pkg.version ?? "0.0.0"; +const requirePackage = createRequire(import.meta.url); +const pkg = requirePackage("../package.json") as PackageJson; + +const GIT_DESCRIBE_RE = + /^v(?\d+\.\d+\.\d+)-(?\d+)-g(?[0-9a-f]{7,40})(?-dirty)?$/i; + +function defaultDebugLog(fields: Record, message: string): void { + console.debug(message, fields); +} + +function defaultGitDescribeCommand(): string { + return execFileSync( + "git", + ["describe", "--tags", "--match", "v*", "--long", "--dirty"], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 1500, + }, + ); +} + +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: { + gitDescribeCommand?: GitDescribeCommand; + packageVersion?: string; + debugLog?: DebugLog; + } = {}, +): string { + const packageVersion = opts.packageVersion ?? pkg.version ?? "0.0.0"; + const gitDescribeCommand = opts.gitDescribeCommand ?? defaultGitDescribeCommand; + const debugLog = opts.debugLog ?? defaultDebugLog; + + try { + const parsedVersion = parseGitDescribeVersion(gitDescribeCommand()); + if (parsedVersion) return parsedVersion; + + debugLog( + { reason: "invalid_git_describe" }, + "falling back to package version for server version", + ); + } catch (err) { + debugLog( + { err, reason: "git_describe_unavailable" }, + "falling back to package version for server version", + ); + } + + return packageVersion; +} + +export const serverVersion = resolveServerVersion();