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:
Dallin Romney 2026-09-10 16:06:37 -07:00 • committed by GitHub
parent bffda1f945
commit df9cb08fd8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 161 additions and 8 deletions

View file

@ -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";
}

View file

@ -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({

View file

@ -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");

View file

@ -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),
};
}

View 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();
});
});

View file

@ -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);

View file

@ -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);

View file

@ -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)
);
}

View file

@ -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) => {