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:
parent
574b4e71db
commit
8516700217
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue