From 0cd7ea0089aea6de8e2b283a16907388788a3569 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 23 Jul 2026 09:52:22 -0700 Subject: [PATCH] fix(update): avoid dirty checkouts after source builds (#113094) * fix(update): prevent build artifact drift * fix(update): preserve excluded UI artifacts --- scripts/write-cli-startup-metadata.ts | 127 ++++++++++++------ src/infra/update-runner-git.ts | 27 +++- src/infra/update-runner.test.ts | 41 ++++++ .../write-cli-startup-metadata.test.ts | 60 ++++++++- ui/src/app/update-overlay-helpers.ts | 2 + 5 files changed, 217 insertions(+), 40 deletions(-) diff --git a/scripts/write-cli-startup-metadata.ts b/scripts/write-cli-startup-metadata.ts index fc5ad820dfcf..3f7a67d92c77 100644 --- a/scripts/write-cli-startup-metadata.ts +++ b/scripts/write-cli-startup-metadata.ts @@ -1,8 +1,16 @@ // Write Cli Startup Metadata script supports OpenClaw repository automation. import { spawn, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { availableParallelism } from "node:os"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { availableParallelism, tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import pMap from "p-map"; @@ -316,10 +324,48 @@ function readBundledChannelCatalog( }; } +function createRootHelpRenderStateDir(): string { + return mkdtempSync(path.join(tmpdir(), "openclaw-build-root-help-")); +} + +function cleanupRootHelpRenderStateDir(stateDir: string): void { + rmSync(stateDir, { force: true, recursive: true }); +} + +function withIsolatedRootHelpRenderContext( + bundledPluginsDir: string, + render: (context: RootHelpRenderContext) => T, +): T { + const stateDir = createRootHelpRenderStateDir(); + try { + const result = render(createIsolatedRootHelpRenderContext(bundledPluginsDir, stateDir)); + if (result instanceof Promise) { + return result.finally(() => cleanupRootHelpRenderStateDir(stateDir)) as T; + } + cleanupRootHelpRenderStateDir(stateDir); + return result; + } catch (error) { + cleanupRootHelpRenderStateDir(stateDir); + throw error; + } +} + +async function settleRootHelpRenderPromises( + values: T, + stateDir: string, +): Promise<{ -readonly [P in keyof T]: Awaited }> { + try { + return await Promise.all(values); + } finally { + await Promise.allSettled(values); + cleanupRootHelpRenderStateDir(stateDir); + } +} + function createIsolatedRootHelpRenderContext( - bundledPluginsDir: string = extensionsDir, + bundledPluginsDir: string, + stateDir: string, ): RootHelpRenderContext { - const stateDir = path.join(rootDir, ".openclaw-build-root-help"); const workspaceDir = path.join(stateDir, "workspace"); const homeDir = path.join(stateDir, "home"); const env: NodeJS.ProcessEnv = { @@ -583,12 +629,17 @@ async function spawnText( export async function renderBundledRootHelpText( _distDirOverride: string = distDir, - renderContext: RootHelpRenderContext = createIsolatedRootHelpRenderContext( - existsSync(path.join(_distDirOverride, "extensions")) - ? path.join(_distDirOverride, "extensions") - : extensionsDir, - ), + renderContext?: RootHelpRenderContext, ): Promise { + if (!renderContext) { + const bundledPluginsDir = existsSync(path.join(_distDirOverride, "extensions")) + ? path.join(_distDirOverride, "extensions") + : extensionsDir; + return await withIsolatedRootHelpRenderContext( + bundledPluginsDir, + async (context) => await renderBundledRootHelpText(_distDirOverride, context), + ); + } const bundleIdentity = resolveCliStartupRootHelpBundleIdentity(_distDirOverride); if (!bundleIdentity) { throw new Error("No root-help bundle found in dist; cannot write CLI startup metadata."); @@ -615,9 +666,10 @@ export async function renderBundledRootHelpText( }); } -function renderSourceRootHelpText( - renderContext: RootHelpRenderContext = createIsolatedRootHelpRenderContext(), -): string { +function renderSourceRootHelpText(renderContext?: RootHelpRenderContext): string { + if (!renderContext) { + return withIsolatedRootHelpRenderContext(extensionsDir, renderSourceRootHelpText); + } const moduleUrl = pathToFileURL(path.join(rootDir, "src/cli/program/root-help.ts")).href; const renderOptions = { pluginSdkResolution: "src", @@ -657,9 +709,7 @@ function renderSourceRootHelpText( return result.stdout ?? ""; } -async function renderSourceBrowserHelpText( - renderContext: RootHelpRenderContext = createIsolatedRootHelpRenderContext(), -): Promise { +async function renderSourceBrowserHelpText(renderContext: RootHelpRenderContext): Promise { // The launcher CLI boot renders byte-identical browser help to a direct // tsx source render (registerBrowserCli + configureProgramHelp) while // avoiding a tsx evaluation of the whole browser CLI import graph, which @@ -669,7 +719,7 @@ async function renderSourceBrowserHelpText( async function renderSourceCommandHelpText( command: SourceCommandHelpCommand, - renderContext: RootHelpRenderContext = createIsolatedRootHelpRenderContext(), + renderContext: RootHelpRenderContext, ): Promise { return await spawnText(["openclaw.mjs", command, "--help"], { cwd: rootDir, @@ -682,21 +732,17 @@ async function renderSourceCommandHelpText( }); } -async function renderSourceSecretsHelpText( - renderContext: RootHelpRenderContext = createIsolatedRootHelpRenderContext(), -): Promise { +async function renderSourceSecretsHelpText(renderContext: RootHelpRenderContext): Promise { return await renderSourceCommandHelpText("secrets", renderContext); } -async function renderSourceNodesHelpText( - renderContext: RootHelpRenderContext = createIsolatedRootHelpRenderContext(), -): Promise { +async function renderSourceNodesHelpText(renderContext: RootHelpRenderContext): Promise { return await renderSourceCommandHelpText("nodes", renderContext); } async function renderSourceCommandHelpTextRecord( commands: readonly SourceCommandHelpCommand[], - renderContext: RootHelpRenderContext = createIsolatedRootHelpRenderContext(), + renderContext: RootHelpRenderContext, ): Promise { const helpTexts = await pMap( commands, @@ -712,7 +758,7 @@ async function renderSourceCommandHelpTextRecord( } async function renderSourceSubcommandHelpTextRecord( - renderContext: RootHelpRenderContext = createIsolatedRootHelpRenderContext(), + renderContext: RootHelpRenderContext, ): Promise { const commandHelpText = await renderSourceCommandHelpTextRecord( PRECOMPUTED_SUBCOMMAND_HELP_COMMANDS, @@ -751,9 +797,6 @@ export async function writeCliStartupMetadata(options?: { const nodesHelpSourceSignature = resolveNodesHelpSourceSignature(resolvedSourceRootDir); const subcommandHelpSourceSignature = resolveSubcommandHelpSourceSignature(resolvedSourceRootDir); const bundledPluginsDir = path.join(resolvedDistDir, "extensions"); - const renderContext = createIsolatedRootHelpRenderContext( - existsSync(bundledPluginsDir) ? bundledPluginsDir : resolvedExtensionsDir, - ); const channelOptions = dedupe([...CORE_CHANNEL_ORDER, ...channelCatalog.ids]); let existing: ExistingCliStartupMetadata | undefined; @@ -821,6 +864,11 @@ export async function writeCliStartupMetadata(options?: { return; } + const renderStateDir = createRootHelpRenderStateDir(); + const renderContext = createIsolatedRootHelpRenderContext( + existsSync(bundledPluginsDir) ? bundledPluginsDir : resolvedExtensionsDir, + renderStateDir, + ); const rootHelpTextPromise = reusableRootHelpText ? Promise.resolve(reusableRootHelpText) : (async () => { @@ -862,21 +910,21 @@ export async function writeCliStartupMetadata(options?: { ? Promise.resolve(reusableBrowserHelpText) : commandHelpTextPromise ? commandHelpTextPromise.then((commandHelpText) => commandHelpText.browser) - : Promise.resolve( + : Promise.resolve().then(() => (options?.renderSourceBrowserHelpText ?? renderSourceBrowserHelpText)(renderContext), ); const secretsHelpTextPromise = reusableSecretsHelpText ? Promise.resolve(reusableSecretsHelpText) : commandHelpTextPromise ? commandHelpTextPromise.then((commandHelpText) => commandHelpText.secrets) - : Promise.resolve( + : Promise.resolve().then(() => (options?.renderSourceSecretsHelpText ?? renderSourceSecretsHelpText)(renderContext), ); const nodesHelpTextPromise = reusableNodesHelpText ? Promise.resolve(reusableNodesHelpText) : commandHelpTextPromise ? commandHelpTextPromise.then((commandHelpText) => commandHelpText.nodes) - : Promise.resolve( + : Promise.resolve().then(() => (options?.renderSourceNodesHelpText ?? renderSourceNodesHelpText)(renderContext), ); const subcommandHelpTextPromise = reusableSubcommandHelpText @@ -891,19 +939,22 @@ export async function writeCliStartupMetadata(options?: { ]), ) as PrecomputedSubcommandHelpText, ) - : Promise.resolve( + : Promise.resolve().then(() => (options?.renderSourceSubcommandHelpTextRecord ?? renderSourceSubcommandHelpTextRecord)( renderContext, ), ); const [rootHelpText, browserHelpText, secretsHelpText, nodesHelpText, subcommandHelpText] = - await Promise.all([ - rootHelpTextPromise, - browserHelpTextPromise, - secretsHelpTextPromise, - nodesHelpTextPromise, - subcommandHelpTextPromise, - ]); + await settleRootHelpRenderPromises( + [ + rootHelpTextPromise, + browserHelpTextPromise, + secretsHelpTextPromise, + nodesHelpTextPromise, + subcommandHelpTextPromise, + ] as const, + renderStateDir, + ); mkdirSync(resolvedDistDir, { recursive: true }); writeFileSync( diff --git a/src/infra/update-runner-git.ts b/src/infra/update-runner-git.ts index e5c674f9f351..4348ab75ea39 100644 --- a/src/infra/update-runner-git.ts +++ b/src/infra/update-runner-git.ts @@ -70,7 +70,7 @@ export async function runGitUpdate(params: { const branch = await readBranchName(runCommand, gitRoot, timeoutMs); const hasDevTargetRef = channel === "dev" && Boolean(opts.devTargetRef?.trim()); const needsCheckoutMain = channel === "dev" && !hasDevTargetRef && branch !== DEV_BRANCH; - const totalSteps = channel === "dev" ? (needsCheckoutMain ? 11 : 10) : 9; + const totalSteps = channel === "dev" ? (needsCheckoutMain ? 12 : 11) : 10; const steps: UpdateStepResult[] = []; let stepIndex = 0; const step = ( @@ -146,6 +146,17 @@ export async function runGitUpdate(params: { return; } await appendRecoveryStep("git rollback clean", ["git", "-C", gitRoot, "reset", "--hard"]); + // Preflight requires a clean checkout outside generated Control UI assets, + // so preserve that excluded directory while removing update-created paths. + await appendRecoveryStep("git rollback clean untracked", [ + "git", + "-C", + gitRoot, + "clean", + "-fd", + "-e", + "dist/control-ui/", + ]); if (branch && branch !== "HEAD") { const checkedOut = await appendRecoveryStep("git rollback checkout", [ "git", @@ -385,6 +396,20 @@ export async function runGitUpdate(params: { if (buildStep.exitCode !== 0) { return await rollbackError("build-failed"); } + const buildCleanCheck = await runStep( + step( + "build clean check", + ["git", "-C", gitRoot, "status", "--porcelain", "--", ":!dist/control-ui/"], + gitRoot, + ), + ); + steps.push(buildCleanCheck); + if (buildCleanCheck.exitCode !== 0) { + return await rollbackError("build-failed"); + } + if (buildCleanCheck.stdoutTail?.trim()) { + return await rollbackError("build-dirty"); + } const uiBuildStep = await runStep( step("ui:build", managerScriptArgs(manager.manager, "ui:build"), gitRoot, manager.env), ); diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 3cbd9790d423..d68bc6f1ee66 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -1548,6 +1548,47 @@ describe("runGatewayUpdate", () => { expect(calls).not.toContain(`git -C ${tempDir} rebase ${olderSha}`); }); + it("cleans and rolls back when a successful build leaves the checkout dirty", async () => { + await setupGitCheckout({ packageManager: "pnpm@8.0.0" }); + const stableTag = "v1.0.1-1"; + const statusCommand = `git -C ${tempDir} status --porcelain -- :!dist/control-ui/`; + const { runner, calls } = createRunner({ + ...buildStableTagResponses(stableTag), + [`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" }, + "pnpm install": { stdout: "" }, + "pnpm build": { stdout: "" }, + }); + let statusCheckCount = 0; + const runCommand = async (argv: string[]) => { + const result = await runner(argv); + if (argv.join(" ") === statusCommand && ++statusCheckCount === 2) { + return toCommandResult({ + stdout: + " M extensions/browser/chrome-extension/modules/copilot-runtime.js\n?? generated-build-output.tmp", + }); + } + return result; + }; + + const result = await runWithCommand(runCommand, { channel: "stable" }); + + expect(result.status).toBe("error"); + expect(result.reason).toBe("build-dirty"); + expect(result.steps).toContainEqual( + expect.objectContaining({ + name: "build clean check", + stdoutTail: + " M extensions/browser/chrome-extension/modules/copilot-runtime.js\n?? generated-build-output.tmp", + }), + ); + expect(calls.filter((call) => call === statusCommand)).toHaveLength(2); + expect(calls).not.toContain("pnpm ui:build"); + expect(calls).toContain(`git -C ${tempDir} reset --hard`); + expect(calls).toContain(`git -C ${tempDir} clean -fd -e dist/control-ui/`); + expect(calls).toContain(`git -C ${tempDir} checkout --force main`); + expect(calls).toContain(`git -C ${tempDir} reset --hard abc123`); + }); + it("returns error and stops early when build fails", async () => { await setupGitCheckout({ packageManager: "pnpm@8.0.0" }); const stableTag = "v1.0.1-1"; diff --git a/test/scripts/write-cli-startup-metadata.test.ts b/test/scripts/write-cli-startup-metadata.test.ts index b8a867f2bd7b..5b83f70a5e7a 100644 --- a/test/scripts/write-cli-startup-metadata.test.ts +++ b/test/scripts/write-cli-startup-metadata.test.ts @@ -1,7 +1,7 @@ // Write Cli Startup Metadata tests cover write cli startup metadata script behavior. import { spawn, spawnSync } from "node:child_process"; import { EventEmitter } from "node:events"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { PassThrough } from "node:stream"; import { pathToFileURL } from "node:url"; @@ -557,6 +557,64 @@ describe("write-cli-startup-metadata", () => { expect(written.nodesHelpText).toContain("openclaw nodes"); }); + it.each([ + { title: "after successful rendering", failRender: false }, + { title: "when rendering fails", failRender: true }, + ])("removes isolated root-help state $title", async ({ failRender }) => { + const tempRoot = createTempDir("openclaw-startup-metadata-cleanup-"); + const distDir = path.join(tempRoot, "dist"); + const extensionsDir = path.join(tempRoot, "extensions"); + const outputPath = path.join(distDir, "cli-startup-metadata.json"); + let stateDir = ""; + let statePresentDuringSiblingRender = false; + + writeStartupMetadataSourceSignatureFixture(tempRoot); + writeFixtureFile(distDir, "root-help-fixture.js", "export function outputRootHelp() {}\n"); + + const writeMetadata = writeCliStartupMetadata({ + distDir, + outputPath, + extensionsDir, + sourceRootDir: tempRoot, + renderBundledRootHelpText: async () => "Usage: openclaw\n", + renderSourceBrowserHelpText: (renderContext) => { + stateDir = renderContext.env?.OPENCLAW_STATE_DIR ?? ""; + const sqliteDir = path.join(stateDir, "state"); + mkdirSync(sqliteDir, { recursive: true }); + for (const suffix of ["", "-shm", "-wal"]) { + writeFileSync(path.join(sqliteDir, `openclaw.sqlite${suffix}`), "fixture", "utf8"); + } + if (failRender) { + throw new Error("browser help failed"); + } + return "Usage: openclaw browser\n"; + }, + renderSourceSecretsHelpText: async () => { + await new Promise((resolve) => setImmediate(resolve)); + statePresentDuringSiblingRender = existsSync(stateDir); + return "Usage: openclaw secrets\n"; + }, + renderSourceNodesHelpText: () => "Usage: openclaw nodes\n", + renderSourceSubcommandHelpTextRecord: () => ({ + doctor: "Usage: openclaw doctor\n", + gateway: "Usage: openclaw gateway\n", + models: "Usage: openclaw models\n", + plugins: "Usage: openclaw plugins\n", + sessions: "Usage: openclaw sessions\n", + tasks: "Usage: openclaw tasks\n", + }), + }); + + if (failRender) { + await expect(writeMetadata).rejects.toThrow("browser help failed"); + } else { + await expect(writeMetadata).resolves.toBeUndefined(); + } + expect(stateDir).not.toBe(""); + expect(statePresentDuringSiblingRender).toBe(true); + expect(existsSync(stateDir)).toBe(false); + }); + it("regenerates nodes help when bundled canvas CLI help sources change", async () => { const tempRoot = createTempDir("openclaw-startup-metadata-signature-"); const distDir = path.join(tempRoot, "dist"); diff --git a/ui/src/app/update-overlay-helpers.ts b/ui/src/app/update-overlay-helpers.ts index d25d2654621e..edbc6d6e5c92 100644 --- a/ui/src/app/update-overlay-helpers.ts +++ b/ui/src/app/update-overlay-helpers.ts @@ -76,6 +76,8 @@ export function resolveUpdateStatusBanner(params: { "Run the update from an OpenClaw checkout or use the CLI global reinstall path.", "deps-install-failed": "Dependency install failed. Fix the install error and retry.", "build-failed": "Build failed. Fix the build error and retry.", + "build-dirty": + "The selected revision's build changed checkout files. Retry with a revision that includes its generated artifacts.", "ui-build-failed": "The control UI rebuild failed. Fix the UI build error and retry.", "global-install-failed": "The global package install did not verify on disk. Retry or reinstall from the CLI.",