mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 14:10:50 +02:00
## 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>
206 lines
5.7 KiB
TypeScript
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();
|