fix(server): preserve source SHA without Git metadata (#9638)
## 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 <noreply@paperclip.ing>
This commit is contained in:
parent
b606869a6a
commit
16b95eece5
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue