diff --git a/server/src/__tests__/build-commit.test.ts b/server/src/__tests__/build-commit.test.ts new file mode 100644 index 0000000000..f76dc8505c --- /dev/null +++ b/server/src/__tests__/build-commit.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; +import { parseBuildCommit, readBuildCommit } from "../build-commit.js"; + +describe("parseBuildCommit", () => { + it("normalizes a full deployment commit", () => { + expect(parseBuildCommit(" ABCDEF0123456789ABCDEF0123456789ABCDEF01\n")).toBe( + "abcdef0123456789abcdef0123456789abcdef01", + ); + }); + + it("rejects truncated and malformed commits", () => { + expect(parseBuildCommit("abcdef0")).toBeNull(); + expect(parseBuildCommit("not-a-commit")).toBeNull(); + }); +}); + +describe("readBuildCommit", () => { + it("prefers an explicit environment commit", () => { + const readTextFile = vi.fn(() => "ffffffffffffffffffffffffffffffffffffffff"); + + expect( + readBuildCommit({ + environmentCommit: "0123456789abcdef0123456789abcdef01234567", + readTextFile, + }), + ).toBe("0123456789abcdef0123456789abcdef01234567"); + expect(readTextFile).not.toHaveBeenCalled(); + }); + + it("reads the deployment marker when no environment commit is set", () => { + expect( + readBuildCommit({ + environmentCommit: null, + buildCommitPath: "/app/.paperclip-build-commit", + readTextFile: (path) => { + expect(path).toBe("/app/.paperclip-build-commit"); + return "0123456789abcdef0123456789abcdef01234567\n"; + }, + }), + ).toBe("0123456789abcdef0123456789abcdef01234567"); + }); +}); diff --git a/server/src/__tests__/server-info.test.ts b/server/src/__tests__/server-info.test.ts index 1af391d431..89f6667583 100644 --- a/server/src/__tests__/server-info.test.ts +++ b/server/src/__tests__/server-info.test.ts @@ -129,6 +129,7 @@ describe("server info snapshot", () => { gitCommand: () => { throw new Error("fatal: not a git repository"); }, + buildCommitCommand: () => null, }); expect(snapshot).toEqual({ @@ -139,6 +140,32 @@ describe("server info snapshot", () => { }, }); }); + + it("uses deployment commit metadata when the runtime has no git directory", () => { + const snapshot = createServerInfoSnapshot({ + now: new Date("2026-06-26T00:00:00.000Z"), + gitCommand: () => { + throw new Error("fatal: not a git repository"); + }, + buildCommitCommand: () => "0123456789abcdef0123456789abcdef01234567", + }); + + expect(snapshot).toEqual({ + processStartedAt: "2026-06-26T00:00:00.000Z", + git: { + available: true, + fullSha: "0123456789abcdef0123456789abcdef01234567", + shortSha: "0123456", + branchName: null, + subject: "Source build", + committedAt: null, + localChanges: { + available: false, + unavailableReason: "git_status_unavailable", + }, + }, + }); + }); }); describe("getServerInfoSnapshot", () => { diff --git a/server/src/__tests__/version.test.ts b/server/src/__tests__/version.test.ts index cd660a3990..d6d717d8e6 100644 --- a/server/src/__tests__/version.test.ts +++ b/server/src/__tests__/version.test.ts @@ -40,6 +40,28 @@ describe("resolveServerVersion", () => { ).toBe("2026.626.0+58.git.518fc71ce"); }); + it("keeps the package version when git describe output is unparseable", () => { + expect( + resolveServerVersion({ + buildCommit: "0123456789abcdef0123456789abcdef01234567", + packageVersion: "2026.706.0", + gitDescribeCommand: () => "canary/v2026.706.0-canary.1", + debugLog: vi.fn(), + }), + ).toBe("2026.706.0"); + }); + + it("keeps the formal version for an exact release tag even when build metadata exists", () => { + expect( + resolveServerVersion({ + buildCommit: "0123456789abcdef0123456789abcdef01234567", + packageVersion: "2026.706.0", + gitDescribeCommand: () => "v2026.706.0-0-g012345678\n", + debugLog: vi.fn(), + }), + ).toBe("2026.706.0"); + }); + it("falls back to package version without throwing when git is unavailable", () => { const debugLog = vi.fn(); const cause = new Error("spawn git ENOENT"); @@ -52,6 +74,7 @@ describe("resolveServerVersion", () => { expect( resolveServerVersion({ + buildCommit: null, packageVersion: "2026.706.0", gitDescribeCommand: () => { throw err; @@ -75,11 +98,25 @@ describe("resolveServerVersion", () => { ); }); + it("uses deployment commit metadata when a source build has no git directory", () => { + expect( + resolveServerVersion({ + buildCommit: "0123456789abcdef0123456789abcdef01234567", + packageVersion: "2026.706.0", + gitDescribeCommand: () => { + throw new Error("fatal: not a git repository"); + }, + debugLog: vi.fn(), + }), + ).toBe("2026.706.0+0.git.0123456"); + }); + it("skips git metadata probing for packaged installs under node_modules", () => { const debugLog = vi.fn(); expect( resolveServerVersion({ + buildCommit: "0123456789abcdef0123456789abcdef01234567", packageVersion: "2026.707.0-canary.12", debugLog, packageRoot: "/tmp/npm/_npx/example/node_modules/@paperclipai/server", @@ -98,6 +135,7 @@ describe("resolveServerVersion", () => { expect( resolveServerVersion({ + buildCommit: null, packageVersion: "2026.707.0-canary.12", debugLog, gitDescribeCommand, @@ -119,6 +157,7 @@ describe("resolveServerVersion", () => { try { expect( resolveServerVersion({ + buildCommit: null, packageVersion: "2026.706.0", gitDescribeCommand: () => { throw new Error("fatal: not a git repository"); diff --git a/server/src/build-commit.ts b/server/src/build-commit.ts new file mode 100644 index 0000000000..105c6d590c --- /dev/null +++ b/server/src/build-commit.ts @@ -0,0 +1,36 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +type ReadTextFile = (path: string) => string; + +const FULL_SHA_RE = /^[0-9a-f]{40}$/i; +const DEFAULT_BUILD_COMMIT_PATH = fileURLToPath( + new URL("../../.paperclip-build-commit", import.meta.url), +); + +export function parseBuildCommit(value: string | null | undefined): string | null { + const commit = value?.trim() ?? ""; + return FULL_SHA_RE.test(commit) ? commit.toLowerCase() : null; +} + +export function readBuildCommit( + opts: { + environmentCommit?: string | null; + buildCommitPath?: string; + readTextFile?: ReadTextFile; + } = {}, +): string | null { + const environmentCommit = parseBuildCommit( + opts.environmentCommit === undefined + ? process.env.PAPERCLIP_BUILD_COMMIT + : opts.environmentCommit, + ); + if (environmentCommit) return environmentCommit; + + try { + const readTextFile = opts.readTextFile ?? ((path: string) => readFileSync(path, "utf8")); + return parseBuildCommit(readTextFile(opts.buildCommitPath ?? DEFAULT_BUILD_COMMIT_PATH)); + } catch { + return null; + } +} diff --git a/server/src/server-info.ts b/server/src/server-info.ts index eb386be6c3..455e622c87 100644 --- a/server/src/server-info.ts +++ b/server/src/server-info.ts @@ -1,11 +1,12 @@ import { execFileSync } from "node:child_process"; import type { ServerGitInfo, ServerGitLocalChanges, ServerInfoSnapshot } from "@paperclipai/shared"; +import { parseBuildCommit, readBuildCommit } from "./build-commit.js"; export type { ServerGitInfo, ServerInfoSnapshot }; type GitCommand = () => string; +type BuildCommitCommand = () => string | null; -const FULL_SHA_RE = /^[0-9a-f]{40}$/i; const SHORT_SHA_RE = /^[0-9a-f]{7,40}$/i; function defaultGitCommand() { @@ -87,15 +88,16 @@ function parseGitInfo( const [fullSha = "", shortSha = "", subject = "", committedAt = ""] = output .trimEnd() .split("\n"); + const parsedFullSha = parseBuildCommit(fullSha); const committedAtTime = Date.parse(committedAt); - if (!FULL_SHA_RE.test(fullSha) || !SHORT_SHA_RE.test(shortSha)) { + if (!parsedFullSha || !SHORT_SHA_RE.test(shortSha)) { return { available: false, unavailableReason: "invalid_git_metadata" }; } return { available: true, - fullSha, + fullSha: parsedFullSha, shortSha, branchName, subject: subject.trim() || "No commit subject", @@ -108,6 +110,7 @@ function readGitInfo( gitCommand: GitCommand = defaultGitCommand, gitStatusCommand: GitCommand = defaultGitStatusCommand, gitBranchCommand: GitCommand = defaultGitBranchCommand, + buildCommitCommand: BuildCommitCommand = readBuildCommit, ): ServerGitInfo { try { const output = gitCommand(); @@ -120,16 +123,43 @@ function readGitInfo( } return parseGitInfo(output, branchName, localChanges); } catch { - return { available: false, unavailableReason: "git_unavailable" }; + const buildCommit = parseBuildCommit(buildCommitCommand()); + if (!buildCommit) { + return { available: false, unavailableReason: "git_unavailable" }; + } + + return { + available: true, + fullSha: buildCommit, + shortSha: buildCommit.slice(0, 7), + branchName: null, + subject: "Source build", + committedAt: null, + localChanges: { + available: false, + unavailableReason: "git_status_unavailable", + }, + }; } } export function createServerInfoSnapshot( - opts: { now?: Date; gitCommand?: GitCommand; gitStatusCommand?: GitCommand; gitBranchCommand?: GitCommand } = {}, + opts: { + now?: Date; + gitCommand?: GitCommand; + gitStatusCommand?: GitCommand; + gitBranchCommand?: GitCommand; + buildCommitCommand?: BuildCommitCommand; + } = {}, ): ServerInfoSnapshot { return { processStartedAt: (opts.now ?? new Date()).toISOString(), - git: readGitInfo(opts.gitCommand, opts.gitStatusCommand, opts.gitBranchCommand), + git: readGitInfo( + opts.gitCommand, + opts.gitStatusCommand, + opts.gitBranchCommand, + opts.buildCommitCommand, + ), }; } @@ -143,12 +173,23 @@ const processStartedAt = new Date().toISOString(); let gitInfoCache: { value: ServerGitInfo; expiresAt: number } | null = null; export function getServerInfoSnapshot( - opts: { now?: number; gitCommand?: GitCommand; gitStatusCommand?: GitCommand; gitBranchCommand?: GitCommand } = {}, + opts: { + now?: number; + gitCommand?: GitCommand; + gitStatusCommand?: GitCommand; + gitBranchCommand?: GitCommand; + buildCommitCommand?: BuildCommitCommand; + } = {}, ): ServerInfoSnapshot { const now = opts.now ?? Date.now(); if (!gitInfoCache || now >= gitInfoCache.expiresAt) { gitInfoCache = { - value: readGitInfo(opts.gitCommand, opts.gitStatusCommand, opts.gitBranchCommand), + value: readGitInfo( + opts.gitCommand, + opts.gitStatusCommand, + opts.gitBranchCommand, + opts.buildCommitCommand, + ), expiresAt: now + GIT_INFO_CACHE_TTL_MS, }; } diff --git a/server/src/version.ts b/server/src/version.ts index e543b79e1d..525c2e2697 100644 --- a/server/src/version.ts +++ b/server/src/version.ts @@ -2,6 +2,7 @@ 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"; type PackageJson = { version?: string; @@ -147,6 +148,7 @@ export function parseGitDescribeVersion(output: string): string | null { export function resolveServerVersion( opts: { + buildCommit?: string | null; gitDescribeCommand?: GitDescribeCommand; packageVersion?: string; debugLog?: DebugLog; @@ -181,6 +183,7 @@ export function resolveServerVersion( { reason: "invalid_git_describe" }, "falling back to package version for server version", ); + return packageVersion; } catch (err) { debugLog( { err: summarizeError(err), reason: "git_describe_unavailable" }, @@ -188,6 +191,14 @@ export function resolveServerVersion( ); } + const buildCommit = + opts.buildCommit === undefined + ? readBuildCommit() + : parseBuildCommit(opts.buildCommit); + if (buildCommit) { + return `${packageVersion}+0.git.${buildCommit.slice(0, 7)}`; + } + return packageVersion; }