From 16b95eece54ef95d9eb5ebfdac952f2bba51f269 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:03:52 -0500 Subject: [PATCH] fix(server): preserve source SHA without Git metadata (#9638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source control plane people use to manage AI-agent companies > - Operators need to identify the exact source build running from the persistent account menu > - PR #9508 added linked source SHA metadata when the server can inspect its Git checkout > - Production images and packaged deployments may not include a `.git` directory even though their build commit is known > - Falling back to the package version in those environments makes the UI look like a formal release and hides the source SHA > - This pull request reads a validated deployment commit marker when Git metadata is unavailable and uses it consistently for server version and server-info responses > - The benefit is that unreleased deployments keep showing an inspectable SHA without changing exact-tag release versions ## Linked Issues or Issue Description Follow-up to #9508. ### Pre-submission checklist - [x] I searched existing open and closed issues and found no duplicate for the no-`.git` deployment fallback. - [x] The behavior reproduces when the server runs without Git metadata but has a known build commit. - [x] The behavior originates in Paperclip's core server build metadata handling, not an adapter, provider, or local configuration. ### What happened? PR #9508 displays source branch and SHA metadata for unreleased builds, but server version and server-info resolution still fall back to the package version when the runtime has no `.git` directory. This is common in production images and packaged deployments. ### Expected behavior When a validated deployment commit is available through `PAPERCLIP_BUILD_COMMIT` or `/app/.paperclip-build-commit`, the server should retain a derived source version and expose SHA metadata even if Git commands are unavailable. Exact release tags should continue using the formal package version. ### Steps to reproduce 1. Build or run Paperclip without a `.git` directory. 2. Provide a full commit SHA through `PAPERCLIP_BUILD_COMMIT` or `/app/.paperclip-build-commit`. 3. Start the server and inspect the version and server-info output. 4. Observe that current `master` returns only the package version and reports Git metadata unavailable. ### Paperclip version or commit Current `master` after #9508. ### Deployment mode Packaged or containerized deployments without runtime Git metadata. ### Installation method Built from source or deployment image. ## What Changed - Add validated build-commit parsing from `PAPERCLIP_BUILD_COMMIT` and `/app/.paperclip-build-commit`. - Preserve source-derived server versions when Git commands are unavailable. - Expose fallback SHA metadata through server-info with an explicit unavailable local-status state. - Keep exact release-tag builds on the formal package version. - Add focused regression tests for parsing, version resolution, and server-info fallback behavior. ## Verification - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/build-commit.test.ts src/__tests__/server-info.test.ts src/__tests__/version.test.ts` - `pnpm --filter @paperclipai/server typecheck` - `git diff --check public/master...HEAD` ## Risks - Low risk: only full 40-character hexadecimal commit values are accepted; malformed or truncated markers preserve the existing fallback behavior. - Deployment tooling must set `PAPERCLIP_BUILD_COMMIT` or write `/app/.paperclip-build-commit` for the fallback to activate. - Fallback server-info cannot provide branch, subject, commit time, or working-tree status without Git metadata, so those fields remain explicitly unavailable. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex using GPT-5.4 with medium reasoning, repository/tool access, shell execution, and code editing; context-window size was not exposed by the runtime. ## 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) - [x] 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 pass - [x] Greptile review is 5/5 with no open P2-or-higher comments, recommendations, or follow-ups --------- Co-authored-by: Paperclip --- server/src/__tests__/build-commit.test.ts | 42 +++++++++++++++++ server/src/__tests__/server-info.test.ts | 27 +++++++++++ server/src/__tests__/version.test.ts | 39 ++++++++++++++++ server/src/build-commit.ts | 36 ++++++++++++++ server/src/server-info.ts | 57 +++++++++++++++++++---- server/src/version.ts | 11 +++++ 6 files changed, 204 insertions(+), 8 deletions(-) create mode 100644 server/src/__tests__/build-commit.test.ts create mode 100644 server/src/build-commit.ts 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; }