mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
fix(update): report the built runtime instead of the unbuilt source version (#143538)
* fix(update): report the built runtime instead of the unbuilt source version
A git checkout that pulls without rebuilding keeps executing the previous dist,
and every surface an operator would check hid that. `openclaw --version` read
the source package.json while the commit came from the build, so a machine
running a 2026.8.1 build reported "2026.9.2 (1623683)" — a version that was
never compiled. `openclaw status` reported HEAD with no hint that dist lagged.
Prefer build-info.json for the version (source runs have none and still fall
back to package.json), and report the built commit in the update row when it
differs from HEAD.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rTGeD4bUUEvA6vupgJt4d
* test(update): cover built-commit provenance reads
Pins the null results that keep source-only checkouts from being reported
as running a stale build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rTGeD4bUUEvA6vupgJt4d
* fix(update): apply built-version precedence to the launcher fast path
`openclaw.mjs` answers bare --version/-V/-v and exits before the runtime
entry loads, so the resolver change alone left the packaged CLI reporting the
source version. Its commit half already preferred dist provenance, which is
how a stale build printed a source version paired with a built commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rTGeD4bUUEvA6vupgJt4d
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bffda1f945
commit
df9cb08fd8
9 changed files with 161 additions and 8 deletions
13
openclaw.mjs
13
openclaw.mjs
|
|
@ -457,16 +457,19 @@ function readLauncherJson(relativePath) {
|
|||
}
|
||||
|
||||
function resolveLauncherVersion() {
|
||||
const packageJson = readLauncherJson("./package.json");
|
||||
const packageVersion = normalizeLauncherMetadataValue(packageJson?.version);
|
||||
if (packageVersion) {
|
||||
return packageVersion;
|
||||
}
|
||||
// Report what is built, not what the source says: resolveLauncherCommit already
|
||||
// prefers dist provenance, so reading package.json first pairs a source version
|
||||
// with a built commit and hides a checkout that pulled without rebuilding.
|
||||
const buildInfo = readLauncherJson("./dist/build-info.json");
|
||||
const buildVersion = normalizeLauncherMetadataValue(buildInfo?.version);
|
||||
if (buildVersion) {
|
||||
return buildVersion;
|
||||
}
|
||||
const packageJson = readLauncherJson("./package.json");
|
||||
const packageVersion = normalizeLauncherMetadataValue(packageJson?.version);
|
||||
if (packageVersion) {
|
||||
return packageVersion;
|
||||
}
|
||||
return normalizeLauncherMetadataValue(process.env.OPENCLAW_BUNDLED_VERSION) ?? "0.0.0";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,50 @@ describe("resolveUpdateAvailability", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("reports a stale build when dist was built from a different commit", () => {
|
||||
const update = buildUpdate({
|
||||
installKind: "git",
|
||||
git: {
|
||||
root: "/tmp/repo",
|
||||
sha: "abc123456789",
|
||||
tag: null,
|
||||
branch: "main",
|
||||
upstream: "origin/main",
|
||||
dirty: false,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
fetchOk: true,
|
||||
builtSha: "def987654321",
|
||||
},
|
||||
});
|
||||
|
||||
// Pulling without rebuilding keeps the old dist running, which is invisible
|
||||
// from HEAD alone and is exactly what a failed update leaves behind.
|
||||
expect(formatUpdateOneLiner(update)).toContain(
|
||||
"stale build (running def98765, run pnpm build)",
|
||||
);
|
||||
});
|
||||
|
||||
it("stays quiet when the built commit matches HEAD", () => {
|
||||
const update = buildUpdate({
|
||||
installKind: "git",
|
||||
git: {
|
||||
root: "/tmp/repo",
|
||||
sha: "abc123456789",
|
||||
tag: null,
|
||||
branch: "main",
|
||||
upstream: "origin/main",
|
||||
dirty: false,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
fetchOk: true,
|
||||
builtSha: "abc123456789",
|
||||
},
|
||||
});
|
||||
|
||||
expect(formatUpdateOneLiner(update)).not.toContain("stale build");
|
||||
});
|
||||
|
||||
it("flags registry update when latest version is newer", () => {
|
||||
const latestVersion = nextMajorVersion(VERSION);
|
||||
const update = buildUpdate({
|
||||
|
|
|
|||
|
|
@ -199,6 +199,11 @@ export function formatUpdateOneLiner(update: UpdateCheckResult): string {
|
|||
if (update.git.fetchOk === false) {
|
||||
parts.push("fetch failed");
|
||||
}
|
||||
// A checkout that pulled but never rebuilt keeps executing the previous dist,
|
||||
// so report the built commit rather than letting HEAD imply what is running.
|
||||
if (update.git.builtSha && update.git.sha && update.git.builtSha !== update.git.sha) {
|
||||
parts.push(`stale build (running ${update.git.builtSha.slice(0, 8)}, run pnpm build)`);
|
||||
}
|
||||
appendRegistryUpdateSummary();
|
||||
} else {
|
||||
parts.push(update.packageManager !== "unknown" ? update.packageManager : "pkg");
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
fetchNpmPackageTargetStatus,
|
||||
type NpmMetadataCommandRunner,
|
||||
} from "./update-check-package-target.js";
|
||||
import { readBuiltRuntimeCommit } from "./update-git-runtime.js";
|
||||
import { updateInstallRootsMatch } from "./update-install-root.js";
|
||||
import type { UpdateFetchFailure } from "./update-run-record.js";
|
||||
|
||||
|
|
@ -40,6 +41,7 @@ type GitUpdateStatus = {
|
|||
ahead: number | null;
|
||||
behind: number | null;
|
||||
fetchOk: boolean | null;
|
||||
builtSha?: string | null;
|
||||
countsCached?: true;
|
||||
stale?: UpdateFetchFailure;
|
||||
error?: string;
|
||||
|
|
@ -430,6 +432,7 @@ async function checkGitUpdateStatus(params: {
|
|||
ahead: parsed ? Number(parsed[1]) : null,
|
||||
behind: parsed ? Number(parsed[2]) : null,
|
||||
fetchOk,
|
||||
builtSha: await readBuiltRuntimeCommit(root),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
54
src/infra/update-git-runtime.test.ts
Normal file
54
src/infra/update-git-runtime.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { readBuiltRuntimeCommit } from "./update-git-runtime.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
async function createRoot(): Promise<string> {
|
||||
// Resolve the temp root: macOS reports /var while prod resolvers return /private/var.
|
||||
const root = await fs.realpath(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-built-commit-")),
|
||||
);
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
while (roots.length > 0) {
|
||||
const root = roots.pop();
|
||||
if (root) {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("readBuiltRuntimeCommit", () => {
|
||||
it("reads the commit the dist was built from", async () => {
|
||||
const root = await createRoot();
|
||||
await fs.mkdir(path.join(root, "dist"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(root, "dist", "build-info.json"),
|
||||
JSON.stringify({ version: "2026.8.1", commit: "1623683f478b1e4a2e3d5632585f006ea142e08c" }),
|
||||
);
|
||||
|
||||
expect(await readBuiltRuntimeCommit(root)).toBe("1623683f478b1e4a2e3d5632585f006ea142e08c");
|
||||
});
|
||||
|
||||
it("returns null when the checkout has no build", async () => {
|
||||
// Source-only checkouts must not be reported as running a stale build.
|
||||
expect(await readBuiltRuntimeCommit(await createRoot())).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when build provenance omits the commit", async () => {
|
||||
const root = await createRoot();
|
||||
await fs.mkdir(path.join(root, "dist"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(root, "dist", "build-info.json"),
|
||||
JSON.stringify({ version: "2026.8.1" }),
|
||||
);
|
||||
|
||||
expect(await readBuiltRuntimeCommit(root)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -42,6 +42,16 @@ export async function collectGitRuntimeErrors(params: GitRuntimeIdentity): Promi
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit the checkout's dist was built from, or null when no build exists.
|
||||
* Comparing it to HEAD is how callers detect a checkout that pulled but never
|
||||
* rebuilt, which otherwise runs old code while reporting the new source version.
|
||||
*/
|
||||
export async function readBuiltRuntimeCommit(root: string): Promise<string | null> {
|
||||
const buildInfo = await tryReadJson(path.join(root, "dist", "build-info.json"));
|
||||
return normalizeNullableString(asNullableRecord(buildInfo)?.commit);
|
||||
}
|
||||
|
||||
export async function readBuiltGatewayBuildId(root: string): Promise<string | null> {
|
||||
const buildInfo = await tryReadJson(path.join(root, "dist", "build-info.json"));
|
||||
const buildId = normalizeNullableString(asNullableRecord(buildInfo)?.buildId);
|
||||
|
|
|
|||
|
|
@ -83,6 +83,18 @@ describe("version resolution", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("reports the built version when dist lags the source package version", async () => {
|
||||
await withVersionFixtureDir(async (root) => {
|
||||
await writeJsonFixture(root, "package.json", { name: "openclaw", version: "2026.9.2" });
|
||||
await writeJsonFixture(root, "build-info.json", { version: "2026.8.1" });
|
||||
const moduleUrl = await ensureModuleFixture(root);
|
||||
// A git checkout that pulled but never rebuilt still executes the old dist,
|
||||
// so reporting the source version hides the stale runtime from operators.
|
||||
expect(readVersionFromPackageJsonForModuleUrl(moduleUrl)).toBe("2026.9.2");
|
||||
expect(resolveVersionFromModuleUrl(moduleUrl)).toBe("2026.8.1");
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the bounded immutable build id from generated provenance", async () => {
|
||||
await withVersionFixtureDir(async (root) => {
|
||||
const moduleUrl = await ensureModuleFixture(root);
|
||||
|
|
|
|||
|
|
@ -91,9 +91,12 @@ export function readBuildIdFromBuildInfoForModuleUrl(moduleUrl: string): string
|
|||
}
|
||||
|
||||
export function resolveVersionFromModuleUrl(moduleUrl: string): string | null {
|
||||
// build-info.json records the version this artifact was built from, so it wins:
|
||||
// a git checkout whose source moved ahead of dist must not report the unbuilt
|
||||
// source version. Source runs have no build-info and fall back to package.json.
|
||||
return (
|
||||
readVersionFromPackageJsonForModuleUrl(moduleUrl) ||
|
||||
readVersionFromBuildInfoForModuleUrl(moduleUrl)
|
||||
readVersionFromBuildInfoForModuleUrl(moduleUrl) ||
|
||||
readVersionFromPackageJsonForModuleUrl(moduleUrl)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const buildCommit = "1234567890abcdef1234567890abcdef12345678";
|
|||
|
||||
type LauncherVersionFixtureOptions = {
|
||||
buildCommit?: string;
|
||||
buildVersion?: string;
|
||||
checkout?: "directory" | "linked";
|
||||
packageCommit?: string;
|
||||
pendingLifecycle?: "complete" | "fail";
|
||||
|
|
@ -68,7 +69,10 @@ async function makeLauncherVersionFixture(
|
|||
if (options.buildCommit) {
|
||||
await fs.writeFile(
|
||||
path.join(fixtureRoot, "dist", "build-info.json"),
|
||||
JSON.stringify({ version: packageVersion, commit: options.buildCommit }),
|
||||
JSON.stringify({
|
||||
version: options.buildVersion ?? packageVersion,
|
||||
commit: options.buildCommit,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -144,6 +148,21 @@ describe("openclaw launcher version provenance", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it("reports the built version when the source package version moved ahead", async () => {
|
||||
const fixtureRoot = await makeLauncherVersionFixture(fixtureRoots, {
|
||||
buildCommit,
|
||||
buildVersion: "2026.8.1",
|
||||
});
|
||||
|
||||
// The launcher answers bare --version before the runtime entry loads, so a
|
||||
// checkout that pulled without rebuilding must not report the unbuilt version.
|
||||
const result = runLauncherVersion(fixtureRoot);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toBe(`OpenClaw 2026.8.1 (${buildCommit.slice(0, 7)})\n`);
|
||||
expect(result.stderr).toBe("");
|
||||
});
|
||||
|
||||
it.each(["--version", "-V", "-v"])(
|
||||
"reports the packaged build for the %s fast path without importing the runtime",
|
||||
async (flag) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue