Files
PaperClipAI/server/src/version.ts
T
DottaandPaperclip 16b95eece5 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>
2026-07-15 20:03:52 -05:00

206 lines
5.7 KiB
TypeScript

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;
};
type GitDescribeCommand = () => string;
type DebugLog = (fields: Record<string, unknown>, message: string) => void;
type PathExists = (path: string) => boolean;
type Realpath = (path: string) => string;
const requirePackage = createRequire(import.meta.url);
const packageRoot = dirname(requirePackage.resolve("../package.json"));
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 {
if (process.env.PAPERCLIP_DEBUG_VERSION_RESOLUTION !== "1") return;
console.debug(message, fields);
}
function defaultGitDescribeCommand(): string {
return execFileSync(
"git",
["describe", "--tags", "--match", "v*", "--long", "--dirty"],
{
cwd: packageRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 1500,
},
);
}
function hasPathSegment(path: string, segment: string): boolean {
return path.split(/[\\/]+/).includes(segment);
}
function safeRealpath(path: string, realpath: Realpath): string {
try {
return realpath(path);
} catch {
return path;
}
}
function hasGitMetadataBeforeNodeModulesBoundary(
path: string,
pathExists: PathExists,
): boolean {
let current = path;
while (true) {
if (pathExists(join(current, ".git"))) return true;
const parent = dirname(current);
if (parent === current || basename(current) === "node_modules") return false;
current = parent;
}
}
function isPackagedInstall(
path: string,
{
pathExists = existsSync,
realpath = realpathSync,
}: { pathExists?: PathExists; realpath?: Realpath } = {},
): boolean {
const realPackageRoot = safeRealpath(path, realpath);
const candidateRoots = Array.from(new Set([path, realPackageRoot]));
const hasNodeModulesSegment = candidateRoots.some((candidate) =>
hasPathSegment(candidate, "node_modules"),
);
if (!hasNodeModulesSegment) return false;
return !candidateRoots.some((candidate) =>
hasGitMetadataBeforeNodeModulesBoundary(candidate, pathExists),
);
}
function normalizeErrorField(value: unknown): unknown {
if (Buffer.isBuffer(value)) return value.toString("utf8");
if (value instanceof Uint8Array) return Buffer.from(value).toString("utf8");
return value;
}
function compactRecord(fields: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(fields).filter(([, value]) => value !== undefined),
);
}
function summarizeError(err: unknown): Record<string, unknown> {
if (err && typeof err === "object") {
const errorLike = err as {
name?: unknown;
message?: unknown;
status?: unknown;
signal?: unknown;
code?: unknown;
stdout?: unknown;
stderr?: unknown;
stack?: unknown;
cause?: unknown;
};
return compactRecord({
name: errorLike.name,
message: errorLike.message,
status: errorLike.status,
signal: errorLike.signal,
code: errorLike.code,
stdout: normalizeErrorField(errorLike.stdout),
stderr: normalizeErrorField(errorLike.stderr),
stack: errorLike.stack,
cause:
errorLike.cause === undefined ? undefined : summarizeError(errorLike.cause),
});
}
return { message: String(err) };
}
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: {
buildCommit?: string | null;
gitDescribeCommand?: GitDescribeCommand;
packageVersion?: string;
debugLog?: DebugLog;
packageRoot?: string;
pathExists?: PathExists;
realpath?: Realpath;
} = {},
): string {
const packageVersion = opts.packageVersion ?? pkg.version ?? "0.0.0";
const gitDescribeCommand = opts.gitDescribeCommand ?? defaultGitDescribeCommand;
const debugLog = opts.debugLog ?? defaultDebugLog;
const resolvedPackageRoot = opts.packageRoot ?? packageRoot;
if (
isPackagedInstall(resolvedPackageRoot, {
pathExists: opts.pathExists,
realpath: opts.realpath,
})
) {
debugLog(
{ reason: "packaged_install" },
"falling back to package version for server version",
);
return packageVersion;
}
try {
const parsedVersion = parseGitDescribeVersion(gitDescribeCommand());
if (parsedVersion) return parsedVersion;
debugLog(
{ reason: "invalid_git_describe" },
"falling back to package version for server version",
);
return packageVersion;
} catch (err) {
debugLog(
{ err: summarizeError(err), reason: "git_describe_unavailable" },
"falling back to package version for server version",
);
}
const buildCommit =
opts.buildCommit === undefined
? readBuildCommit()
: parseBuildCommit(opts.buildCommit);
if (buildCommit) {
return `${packageVersion}+0.git.${buildCommit.slice(0, 7)}`;
}
return packageVersion;
}
export const serverVersion = resolveServerVersion();