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