mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-16 05:17:41 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work; it ships as a Docker image that self-hosters and managed deployments run. > - The server resolves its own version at runtime in `server/src/version.ts` (`resolveServerVersion()`), which feeds analytics and the server debug panel. > - That resolver derives the real version from `git describe`, and falls back to `server/package.json`'s `version` when git isn't available. > - But `server/package.json`'s version is a static placeholder — CI only stamps the real CalVer at publish, so in source it is never the real version (currently `0.3.1`). > - A Docker image has no `.git` (it's dockerignored), so `git describe` can't run inside it. Every image therefore falls back to the placeholder and reports `0.3.1` in analytics and the debug panel, regardless of which commit it was built from. > - This PR computes the real version once on the CI build runner (where `.git` and tags exist), bakes it into the image, and has `resolveServerVersion()` prefer that stamp when `git describe` is unavailable. > - The benefit: self-hosted and cloud images report their true version instead of a misleading placeholder, with no change to dev checkouts, `git describe`-based resolution, or local `docker build`. ## Linked Issues or Issue Description No public issue exists — describing the bug inline (per the bug report template). **What happened?** Docker images built from `master` (and release tags) report the server version as the `0.3.1` placeholder in analytics and the server debug panel, instead of the real version of the commit the image was built from. **Expected behavior** An image reports the real version of its build commit (e.g. `2026.722.0+51.git.<sha>`), so operators can tell which build is running. **Steps to reproduce** 1. Build the server Docker image from any `master` commit (the `Docker` workflow, `production` target). 2. Run the image and open the server debug panel (or inspect the version reported to analytics). 3. Observe the version is `0.3.1` rather than the commit's real version. **Root cause** `resolveServerVersion()` derives the real version from `git describe`, but the image has no `.git` (dockerignored), so it falls back to `server/package.json`'s `version` — a static placeholder CI only replaces with the real CalVer at publish time. Nothing bakes the real version into the image. **Paperclip version or commit:** reproduces on `master` (`4c55f0d8`) and any published image. **Deployment mode:** self-hosted and managed (both the `production` and `-cloud` images). **Installation method:** Docker image (`ghcr.io/paperclipai/paperclip`). **Related PRs (dedup search):** #9103 (merged — added the `git describe`-based source-install resolution this builds on) and #9637 (closed). Neither bakes a version into the image; this PR closes that gap. No duplicate found. ## What Changed - **`.github/workflows/docker.yml`** — checkout with full history + tags (`fetch-depth: 0`), and a new `Compute build version` step that runs `git describe --tags --match 'v*' --long --dirty` on the pristine runner checkout. The result is passed as a `PAPERCLIP_BUILD_VERSION` build-arg to both the `production` and `-cloud` image builds. - **`Dockerfile`** — the `production` stage takes an `ARG PAPERCLIP_BUILD_VERSION` (default empty) and bakes it into the runtime `ENV`; the `cloud` stage inherits it via `FROM production`. - **`server/src/build-version.ts`** (new) — `readBuildVersion()` / `parseBuildVersion()`, mirroring `build-commit.ts`: reads `PAPERCLIP_BUILD_VERSION` (or a `.paperclip-build-version` file) as a single-token stamp. - **`server/src/version.ts`** — `resolveServerVersion()` prefers the baked build version when `git describe` is unavailable, parsing it with the same rules as a live checkout (`parseGitDescribeVersion`), and falling through to the existing `build-commit` stamp and package version when unset. A live checkout's `git describe` still wins over any stamp. - Tests for the new behavior and the precedence. ## Verification - `pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit` in `server/` — clean. - `vitest run server/src/__tests__/version.test.ts server/src/__tests__/build-version.test.ts` — **23 tests pass**, covering: stamped version used when git describe fails, stamp parsed to real CalVer, stamp preferred over the build-commit fallback, on-tag stamp collapses to the release version, a pre-resolved stamp used verbatim, and a live git describe still winning over a stamp. - `git describe --tags --match 'v*' --long` for this commit → `v2026.722.0-51-g<sha>`, which `resolveServerVersion()` reports as `2026.722.0+51.git.<sha>` — no longer `0.3.1`. - Not run locally: the full multi-arch image build (CI-only). The workflow change is verified by inspection; the version is computed on the pristine checkout before any lockfile refresh, so it carries no spurious `-dirty`. ## Risks Low. Additive and image-only: - No runtime behavior changes for dev checkouts (git describe still primary and wins over any stamp) or for local `docker build` (empty arg → server keeps its existing fallbacks). - Not a breaking change; no schema or API surface. The stamp is informational (version reporting only). - `fetch-depth: 0` makes the release-image checkout fetch full history/tags — a modest cost on a workflow that already runs at release cadence with a 60-minute budget. - Rollback: revert the commit; images simply return to reporting the placeholder. ## Model Used Claude Opus 4.8 (`claude-opus-4-8`, 1M-context variant), extended thinking, with tool use / code execution — agentic edits, `tsc` + `vitest` runs, and a `git describe` resolution check. ## 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 (bugfix, not core feature work) - [x] I have searched GitHub for duplicate or related PRs and linked them above (#9103, #9637 — related, not duplicates) - [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 (`fix/build-version-stamp`) 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 (no user-facing docs affected; behavior is documented inline in `version.ts` / `build-version.ts` and the workflow/Dockerfile) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
240 lines
7.7 KiB
TypeScript
240 lines
7.7 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { parseGitDescribeVersion, resolveServerVersion } from "../version.js";
|
|
|
|
describe("parseGitDescribeVersion", () => {
|
|
it("reports drift from the nearest release tag as a PEP 440 local version", () => {
|
|
expect(parseGitDescribeVersion("v2026.626.0-58-g518fc71ce\n")).toBe(
|
|
"2026.626.0+58.git.518fc71ce",
|
|
);
|
|
});
|
|
|
|
it("collapses clean on-tag checkouts to the release version", () => {
|
|
expect(parseGitDescribeVersion("v2026.626.0-0-g012345678\n")).toBe("2026.626.0");
|
|
});
|
|
|
|
it("adds dirty state to the local version segment", () => {
|
|
expect(parseGitDescribeVersion("v2026.626.0-58-g518fc71ce-dirty\n")).toBe(
|
|
"2026.626.0+58.git.518fc71ce.dirty",
|
|
);
|
|
});
|
|
|
|
it("returns null for unparseable describe output so callers can fall back", () => {
|
|
expect(parseGitDescribeVersion("canary/v2026.706.0-canary.1")).toBeNull();
|
|
});
|
|
|
|
it("appends dirty suffix even when on-tag (zero commits since tag)", () => {
|
|
expect(parseGitDescribeVersion("v2026.626.0-0-g012345678-dirty\n")).toBe(
|
|
"2026.626.0+0.git.012345678.dirty",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("resolveServerVersion", () => {
|
|
it("returns the parsed git-derived version when git describe succeeds", () => {
|
|
expect(
|
|
resolveServerVersion({
|
|
packageVersion: "2026.706.0",
|
|
gitDescribeCommand: () => "v2026.626.0-58-g518fc71ce\n",
|
|
debugLog: vi.fn(),
|
|
}),
|
|
).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");
|
|
const err = Object.assign(new Error("fatal: not a git repository"), {
|
|
code: 128,
|
|
stderr: Buffer.from("fatal: not a git repository\n"),
|
|
stdout: "",
|
|
cause,
|
|
});
|
|
|
|
expect(
|
|
resolveServerVersion({
|
|
buildCommit: null,
|
|
packageVersion: "2026.706.0",
|
|
gitDescribeCommand: () => {
|
|
throw err;
|
|
},
|
|
debugLog,
|
|
}),
|
|
).toBe("2026.706.0");
|
|
expect(debugLog).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
err: expect.objectContaining({
|
|
cause: expect.objectContaining({ message: "spawn git ENOENT" }),
|
|
code: 128,
|
|
message: "fatal: not a git repository",
|
|
stderr: "fatal: not a git repository\n",
|
|
stdout: "",
|
|
stack: expect.any(String),
|
|
}),
|
|
reason: "git_describe_unavailable",
|
|
}),
|
|
"falling back to package version for server version",
|
|
);
|
|
});
|
|
|
|
it("uses deployment commit metadata when a source build has no git directory", () => {
|
|
expect(
|
|
resolveServerVersion({
|
|
buildVersion: null,
|
|
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("uses the stamped build version when a Docker image has no git directory", () => {
|
|
const debugLog = vi.fn();
|
|
|
|
expect(
|
|
resolveServerVersion({
|
|
// A real CalVer describe stamped by CI wins over the coarse build-commit
|
|
// stamp and the source placeholder — this is the analytics/debug-panel fix.
|
|
buildVersion: "v2026.722.0-15-g4c55f0d",
|
|
buildCommit: "0123456789abcdef0123456789abcdef01234567",
|
|
packageVersion: "0.3.1",
|
|
gitDescribeCommand: () => {
|
|
throw new Error("fatal: not a git repository");
|
|
},
|
|
debugLog,
|
|
}),
|
|
).toBe("2026.722.0+15.git.4c55f0d");
|
|
expect(debugLog).toHaveBeenCalledWith(
|
|
{ reason: "build_version" },
|
|
"using stamped build version for server version",
|
|
);
|
|
});
|
|
|
|
it("collapses an on-tag stamped build version to the release version", () => {
|
|
expect(
|
|
resolveServerVersion({
|
|
buildVersion: "v2026.722.0-0-g4c55f0d",
|
|
packageVersion: "0.3.1",
|
|
gitDescribeCommand: () => {
|
|
throw new Error("no git");
|
|
},
|
|
debugLog: vi.fn(),
|
|
}),
|
|
).toBe("2026.722.0");
|
|
});
|
|
|
|
it("uses a pre-resolved stamped build version verbatim", () => {
|
|
expect(
|
|
resolveServerVersion({
|
|
buildVersion: "2026.725.0-canary.2",
|
|
packageVersion: "0.3.1",
|
|
gitDescribeCommand: () => {
|
|
throw new Error("no git");
|
|
},
|
|
debugLog: vi.fn(),
|
|
}),
|
|
).toBe("2026.725.0-canary.2");
|
|
});
|
|
|
|
it("keeps the live git-derived version even when a build version is stamped", () => {
|
|
expect(
|
|
resolveServerVersion({
|
|
// A stamped version is only a fallback: a real checkout's git describe wins.
|
|
buildVersion: "v2020.1.1-0-g0000000",
|
|
packageVersion: "0.3.1",
|
|
gitDescribeCommand: () => "v2026.626.0-58-g518fc71ce\n",
|
|
debugLog: vi.fn(),
|
|
}),
|
|
).toBe("2026.626.0+58.git.518fc71ce");
|
|
});
|
|
|
|
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",
|
|
}),
|
|
).toBe("2026.707.0-canary.12");
|
|
|
|
expect(debugLog).toHaveBeenCalledWith(
|
|
{ reason: "packaged_install" },
|
|
"falling back to package version for server version",
|
|
);
|
|
});
|
|
|
|
it("uses git metadata for source checkouts whose path contains node_modules", () => {
|
|
const debugLog = vi.fn();
|
|
const gitDescribeCommand = vi.fn(() => "v2026.626.0-58-g518fc71ce\n");
|
|
|
|
expect(
|
|
resolveServerVersion({
|
|
buildCommit: null,
|
|
packageVersion: "2026.707.0-canary.12",
|
|
debugLog,
|
|
gitDescribeCommand,
|
|
packageRoot: "/tmp/node_modules/source/paperclip/server",
|
|
pathExists: (path) => path === "/tmp/node_modules/source/paperclip/.git",
|
|
realpath: (path) => path,
|
|
}),
|
|
).toBe("2026.626.0+58.git.518fc71ce");
|
|
|
|
expect(gitDescribeCommand).toHaveBeenCalledOnce();
|
|
expect(debugLog).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps fallback diagnostics quiet by default", () => {
|
|
const previousDebugFlag = process.env.PAPERCLIP_DEBUG_VERSION_RESOLUTION;
|
|
delete process.env.PAPERCLIP_DEBUG_VERSION_RESOLUTION;
|
|
const consoleDebug = vi.spyOn(console, "debug").mockImplementation(() => {});
|
|
|
|
try {
|
|
expect(
|
|
resolveServerVersion({
|
|
buildCommit: null,
|
|
packageVersion: "2026.706.0",
|
|
gitDescribeCommand: () => {
|
|
throw new Error("fatal: not a git repository");
|
|
},
|
|
}),
|
|
).toBe("2026.706.0");
|
|
|
|
expect(consoleDebug).not.toHaveBeenCalled();
|
|
} finally {
|
|
consoleDebug.mockRestore();
|
|
if (previousDebugFlag === undefined) {
|
|
delete process.env.PAPERCLIP_DEBUG_VERSION_RESOLUTION;
|
|
} else {
|
|
process.env.PAPERCLIP_DEBUG_VERSION_RESOLUTION = previousDebugFlag;
|
|
}
|
|
}
|
|
});
|
|
});
|