fix(server): report source-install version from git metadata (#9103)

## 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.<sha>` (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 <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-07-06 09:15:44 -07:00 committed by GitHub
parent 574b4e71db
commit 8516700217
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 131 additions and 3 deletions

View File

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

View File

@ -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<string, unknown>, 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(?<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 {
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();