From 47ccc3d9bb3c6927658c8cf88796a4808b826dc6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Apr 2026 23:53:25 +0100 Subject: [PATCH] fix: bootstrap pnpm for git updates --- src/infra/update-runner.test.ts | 159 +++++++++++-- src/infra/update-runner.ts | 387 ++++++++++++++++++++------------ 2 files changed, 380 insertions(+), 166 deletions(-) diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index baa08800505..09f8caf2dc1 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -168,7 +168,10 @@ describe("runGatewayUpdate", () => { buildCommand: string; uiBuildCommand: string; doctorCommand: string; - onCommand?: (key: string) => Promise | CommandResponse | undefined; + onCommand?: ( + key: string, + options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number }, + ) => Promise | CommandResponse | undefined; }) { const calls: string[] = []; const responses = { @@ -179,10 +182,13 @@ describe("runGatewayUpdate", () => { [params.doctorCommand]: { stdout: "" }, } satisfies Record; - const runCommand = async (argv: string[]) => { + const runCommand = async ( + argv: string[], + options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number }, + ) => { const key = argv.join(" "); calls.push(key); - const override = await params.onCommand?.(key); + const override = await params.onCommand?.(key, options); if (override) { return toCommandResult(override); } @@ -201,7 +207,7 @@ describe("runGatewayUpdate", () => { argv: string[], options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number }, ) => Promise, - options?: { channel?: "stable" | "beta"; tag?: string; cwd?: string }, + options?: { channel?: "stable" | "beta" | "dev"; tag?: string; cwd?: string }, ) { return runGatewayUpdate({ cwd: options?.cwd ?? tempDir, @@ -214,7 +220,7 @@ describe("runGatewayUpdate", () => { async function runWithRunner( runner: (argv: string[]) => Promise, - options?: { channel?: "stable" | "beta"; tag?: string; cwd?: string }, + options?: { channel?: "stable" | "beta" | "dev"; tag?: string; cwd?: string }, ) { return runWithCommand(runner, options); } @@ -376,39 +382,44 @@ describe("runGatewayUpdate", () => { expect(calls).not.toContain(`git -C ${tempDir} checkout --detach ${betaTag}`); }); - it("falls back to npm when pnpm is unavailable for git installs", async () => { + it("bootstraps pnpm via npm when pnpm and corepack are unavailable", async () => { await setupGitPackageManagerFixture(); const stableTag = "v1.0.1-1"; const { calls, runCommand } = createGitInstallRunner({ stableTag, - installCommand: "npm install --no-package-lock --legacy-peer-deps", - buildCommand: "npm run build", - uiBuildCommand: "npm run ui:build", + installCommand: "pnpm install", + buildCommand: "pnpm build", + uiBuildCommand: "pnpm ui:build", doctorCommand: `${process.execPath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive`, - onCommand: (key) => { + onCommand: (key, options) => { if (key === "pnpm --version") { + const envPath = options?.env?.PATH ?? options?.env?.Path ?? ""; + if (envPath.includes("openclaw-update-pnpm-")) { + return { stdout: "10.0.0" }; + } throw new Error("spawn pnpm ENOENT"); } + if (key === "corepack --version") { + throw new Error("spawn corepack ENOENT"); + } if (key === "npm --version") { return { stdout: "10.0.0" }; } + if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@10")) { + return { stdout: "added 1 package" }; + } return undefined; }, }); - const result = await runGatewayUpdate({ - cwd: tempDir, - runCommand: async (argv, _options) => runCommand(argv), - timeoutMs: 5000, - channel: "stable", - }); + const result = await runWithCommand(runCommand, { channel: "stable" }); expect(result.status).toBe("ok"); expect(calls).toContain("pnpm --version"); - expect(calls).toContain("corepack --version"); + expect(calls.some((call) => call.startsWith("npm install --prefix "))).toBe(true); expect(calls).toContain("npm --version"); - expect(calls).toContain("npm install --no-package-lock --legacy-peer-deps"); - expect(calls).not.toContain("pnpm install"); + expect(calls).toContain("pnpm install"); + expect(calls).not.toContain("npm install --no-package-lock --legacy-peer-deps"); }); it("bootstraps pnpm via corepack when pnpm is missing", async () => { @@ -452,6 +463,116 @@ describe("runGatewayUpdate", () => { expect(calls).not.toContain("npm install --no-package-lock --legacy-peer-deps"); }); + it("uses npm-bootstrapped pnpm for dev preflight when pnpm and corepack are missing", async () => { + await setupGitPackageManagerFixture(); + const calls: string[] = []; + const pnpmEnvPaths: string[] = []; + const upstreamSha = "upstream123"; + const doctorNodePath = await resolveStableNodePath(process.execPath); + const doctorCommand = `${doctorNodePath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive --fix`; + + const runCommand = async ( + argv: string[], + options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number }, + ) => { + const key = argv.join(" "); + calls.push(key); + + if (key === `git -C ${tempDir} rev-parse --show-toplevel`) { + return { stdout: tempDir, stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse HEAD`) { + return { stdout: "abc123", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse --abbrev-ref HEAD`) { + return { stdout: "main", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} status --porcelain -- :!dist/control-ui/`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`) { + return { stdout: "origin/main", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} fetch --all --prune --tags`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse @{upstream}`) { + return { stdout: upstreamSha, stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-list --max-count=10 ${upstreamSha}`) { + return { stdout: `${upstreamSha}\n`, stderr: "", code: 0 }; + } + if (key === "pnpm --version") { + const envPath = options?.env?.PATH ?? options?.env?.Path ?? ""; + if (envPath.includes("openclaw-update-pnpm-")) { + pnpmEnvPaths.push(envPath); + return { stdout: "10.0.0", stderr: "", code: 0 }; + } + throw new Error("spawn pnpm ENOENT"); + } + if (key === "corepack --version") { + throw new Error("spawn corepack ENOENT"); + } + if (key === "npm --version") { + return { stdout: "10.0.0", stderr: "", code: 0 }; + } + if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@10")) { + return { stdout: "added 1 package", stderr: "", code: 0 }; + } + if ( + key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/openclaw-update-preflight-`) && + key.endsWith(` /worktree ${upstreamSha}`) + ) { + return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 }; + } + if ( + key.startsWith("git -C /tmp/openclaw-update-preflight-") && + key.includes("/worktree checkout --detach ") && + key.endsWith(upstreamSha) + ) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === "pnpm install" || key === "pnpm build" || key === "pnpm lint") { + const envPath = options?.env?.PATH ?? options?.env?.Path ?? ""; + pnpmEnvPaths.push(envPath); + return { stdout: "", stderr: "", code: 0 }; + } + if ( + key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/openclaw-update-preflight-`) + ) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} worktree prune`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rebase ${upstreamSha}`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === "pnpm ui:build") { + const envPath = options?.env?.PATH ?? options?.env?.Path ?? ""; + pnpmEnvPaths.push(envPath); + return { stdout: "", stderr: "", code: 0 }; + } + if (key === doctorCommand) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse HEAD`) { + return { stdout: upstreamSha, stderr: "", code: 0 }; + } + return { stdout: "", stderr: "", code: 0 }; + }; + + const result = await runWithCommand(runCommand, { channel: "dev" }); + + expect(result.status).toBe("ok"); + expect(calls.some((call) => call.startsWith("npm install --prefix "))).toBe(true); + expect(calls).toContain("pnpm install"); + expect(calls).toContain("pnpm build"); + expect(calls).toContain("pnpm lint"); + expect(calls).toContain("pnpm ui:build"); + expect(pnpmEnvPaths.some((value) => value.includes("openclaw-update-pnpm-"))).toBe(true); + }); + it("skips update when no git root", async () => { await fs.writeFile( path.join(tempDir, "package.json"), diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index be8be307b10..2bcd20e5c9c 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -9,6 +9,7 @@ import { import { detectPackageManager as detectPackageManagerImpl } from "./detect-package-manager.js"; import { readPackageName, readPackageVersion } from "./package-json.js"; import { normalizePackageTagInput } from "./package-tag.js"; +import { applyPathPrepend } from "./path-prepend.js"; import { trimLogTail } from "./restart-sentinel.js"; import { resolveStableNodePath } from "./stable-node-path.js"; import { @@ -87,6 +88,12 @@ type UpdateRunnerOptions = { }; type BuildManager = "pnpm" | "bun" | "npm"; +type ResolvedBuildManager = { + manager: BuildManager; + fallback: boolean; + env?: NodeJS.ProcessEnv; + cleanup?: () => Promise; +}; const DEFAULT_TIMEOUT_MS = 20 * 60_000; const MAX_LOG_CHARS = 8000; @@ -275,9 +282,10 @@ async function isManagerAvailable( runCommand: CommandRunner, manager: BuildManager, timeoutMs: number, + env?: NodeJS.ProcessEnv, ): Promise { try { - const res = await runCommand(managerVersionArgs(manager), { timeoutMs }); + const res = await runCommand(managerVersionArgs(manager), { timeoutMs, env }); return res.code === 0; } catch { return false; @@ -288,44 +296,107 @@ async function isCommandAvailable( runCommand: CommandRunner, argv: string[], timeoutMs: number, + env?: NodeJS.ProcessEnv, ): Promise { try { - const res = await runCommand(argv, { timeoutMs }); + const res = await runCommand(argv, { timeoutMs, env }); return res.code === 0; } catch { return false; } } -async function ensurePnpmAvailable(runCommand: CommandRunner, timeoutMs: number): Promise { - if (await isManagerAvailable(runCommand, "pnpm", timeoutMs)) { +async function ensurePnpmAvailable( + runCommand: CommandRunner, + timeoutMs: number, + env?: NodeJS.ProcessEnv, +): Promise { + if (await isManagerAvailable(runCommand, "pnpm", timeoutMs, env)) { return true; } - if (!(await isCommandAvailable(runCommand, ["corepack", "--version"], timeoutMs))) { + if (!(await isCommandAvailable(runCommand, ["corepack", "--version"], timeoutMs, env))) { return false; } try { - const res = await runCommand(["corepack", "enable"], { timeoutMs }); + const res = await runCommand(["corepack", "enable"], { timeoutMs, env }); if (res.code !== 0) { return false; } } catch { return false; } - return await isManagerAvailable(runCommand, "pnpm", timeoutMs); + return await isManagerAvailable(runCommand, "pnpm", timeoutMs, env); +} + +function cloneCommandEnv(env?: NodeJS.ProcessEnv): Record { + return Object.fromEntries( + Object.entries(env ?? process.env) + .filter(([, value]) => value != null) + .map(([key, value]) => [key, String(value)]), + ) as Record; +} + +async function bootstrapPnpmViaNpm(params: { + runCommand: CommandRunner; + timeoutMs: number; + baseEnv?: NodeJS.ProcessEnv; +}): Promise<{ env: NodeJS.ProcessEnv; cleanup: () => Promise } | null> { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-pnpm-")); + const cleanup = async () => { + await fs.rm(tempRoot, { recursive: true, force: true }).catch(() => {}); + }; + try { + const installResult = await params.runCommand( + ["npm", "install", "--prefix", tempRoot, "pnpm@10"], + { + timeoutMs: params.timeoutMs, + env: params.baseEnv, + }, + ); + if (installResult.code !== 0) { + await cleanup(); + return null; + } + const env = cloneCommandEnv(params.baseEnv); + applyPathPrepend(env, [path.join(tempRoot, "node_modules", ".bin")]); + if (!(await isManagerAvailable(params.runCommand, "pnpm", params.timeoutMs, env))) { + await cleanup(); + return null; + } + return { env, cleanup }; + } catch { + await cleanup(); + return null; + } } async function resolveAvailableManager( runCommand: CommandRunner, root: string, timeoutMs: number, -): Promise<{ manager: BuildManager; fallback: boolean }> { + baseEnv?: NodeJS.ProcessEnv, +): Promise { const preferred = await detectPackageManager(root); - if (preferred === "pnpm" && (await ensurePnpmAvailable(runCommand, timeoutMs))) { + if (preferred === "pnpm" && (await ensurePnpmAvailable(runCommand, timeoutMs, baseEnv))) { return { manager: "pnpm", fallback: false }; } + if (preferred === "pnpm" && (await isManagerAvailable(runCommand, "npm", timeoutMs, baseEnv))) { + const pnpmBootstrap = await bootstrapPnpmViaNpm({ + runCommand, + timeoutMs, + baseEnv, + }); + if (pnpmBootstrap) { + return { + manager: "pnpm", + fallback: false, + env: pnpmBootstrap.env, + cleanup: pnpmBootstrap.cleanup, + }; + } + } for (const manager of managerPreferenceOrder(preferred)) { - if (await isManagerAvailable(runCommand, manager, timeoutMs)) { + if (await isManagerAvailable(runCommand, manager, timeoutMs, baseEnv)) { return { manager, fallback: manager !== preferred }; } } @@ -644,7 +715,12 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< }; } - const manager = await resolveAvailableManager(runCommand, gitRoot, timeoutMs); + const manager = await resolveAvailableManager( + runCommand, + gitRoot, + timeoutMs, + defaultCommandEnv, + ); const preflightRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-preflight-")); const worktreeDir = path.join(preflightRoot, "worktree"); const worktreeStep = await runStep( @@ -691,6 +767,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< compatFallback: manager.fallback && manager.manager === "npm", }), worktreeDir, + manager.env, ), ); steps.push(depsStep); @@ -703,6 +780,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< `preflight build (${shortSha})`, managerScriptArgs(manager.manager, "build"), worktreeDir, + manager.env, ), ); steps.push(buildStep); @@ -715,6 +793,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< `preflight lint (${shortSha})`, managerScriptArgs(manager.manager, "lint"), worktreeDir, + manager.env, ), ); steps.push(lintStep); @@ -739,6 +818,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< timeoutMs, }).catch(() => null); await fs.rm(preflightRoot, { recursive: true, force: true }).catch(() => {}); + await manager.cleanup?.(); } if (!selectedSha) { @@ -824,168 +904,181 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< } } - const manager = await resolveAvailableManager(runCommand, gitRoot, timeoutMs); - - const depsStep = await runStep( - step( - "deps install", - managerInstallArgs(manager.manager, { - compatFallback: manager.fallback && manager.manager === "npm", - }), - gitRoot, - ), + const manager = await resolveAvailableManager( + runCommand, + gitRoot, + timeoutMs, + defaultCommandEnv, ); - steps.push(depsStep); - if (depsStep.exitCode !== 0) { - return { - status: "error", - mode: "git", - root: gitRoot, - reason: "deps-install-failed", - before: { sha: beforeSha, version: beforeVersion }, - steps, - durationMs: Date.now() - startedAt, - }; - } - - const buildStep = await runStep( - step("build", managerScriptArgs(manager.manager, "build"), gitRoot), - ); - steps.push(buildStep); - if (buildStep.exitCode !== 0) { - return { - status: "error", - mode: "git", - root: gitRoot, - reason: "build-failed", - before: { sha: beforeSha, version: beforeVersion }, - steps, - durationMs: Date.now() - startedAt, - }; - } - - const uiBuildStep = await runStep( - step("ui:build", managerScriptArgs(manager.manager, "ui:build"), gitRoot), - ); - steps.push(uiBuildStep); - if (uiBuildStep.exitCode !== 0) { - return { - status: "error", - mode: "git", - root: gitRoot, - reason: "ui-build-failed", - before: { sha: beforeSha, version: beforeVersion }, - steps, - durationMs: Date.now() - startedAt, - }; - } - - const doctorEntry = path.join(gitRoot, "openclaw.mjs"); - const doctorEntryExists = await fs - .stat(doctorEntry) - .then(() => true) - .catch(() => false); - if (!doctorEntryExists) { - steps.push({ - name: "openclaw doctor entry", - command: `verify ${doctorEntry}`, - cwd: gitRoot, - durationMs: 0, - exitCode: 1, - stderrTail: `missing ${doctorEntry}`, - }); - return { - status: "error", - mode: "git", - root: gitRoot, - reason: "doctor-entry-missing", - before: { sha: beforeSha, version: beforeVersion }, - steps, - durationMs: Date.now() - startedAt, - }; - } - - // Use --fix so that doctor auto-strips unknown config keys introduced by - // schema changes between versions, preventing a startup validation crash. - const doctorNodePath = await resolveStableNodePath(process.execPath); - const doctorArgv = [doctorNodePath, doctorEntry, "doctor", "--non-interactive", "--fix"]; - const doctorStep = await runStep( - step("openclaw doctor", doctorArgv, gitRoot, { OPENCLAW_UPDATE_IN_PROGRESS: "1" }), - ); - steps.push(doctorStep); - - const uiIndexHealth = await resolveControlUiDistIndexHealth({ root: gitRoot }); - if (!uiIndexHealth.exists) { - const repairArgv = managerScriptArgs(manager.manager, "ui:build"); - const started = Date.now(); - const repairResult = await runCommand(repairArgv, { cwd: gitRoot, timeoutMs }); - const repairStep: UpdateStepResult = { - name: "ui:build (post-doctor repair)", - command: repairArgv.join(" "), - cwd: gitRoot, - durationMs: Date.now() - started, - exitCode: repairResult.code, - stdoutTail: trimLogTail(repairResult.stdout, MAX_LOG_CHARS), - stderrTail: trimLogTail(repairResult.stderr, MAX_LOG_CHARS), - }; - steps.push(repairStep); - - if (repairResult.code !== 0) { + try { + const depsStep = await runStep( + step( + "deps install", + managerInstallArgs(manager.manager, { + compatFallback: manager.fallback && manager.manager === "npm", + }), + gitRoot, + manager.env, + ), + ); + steps.push(depsStep); + if (depsStep.exitCode !== 0) { return { status: "error", mode: "git", root: gitRoot, - reason: repairStep.name, + reason: "deps-install-failed", before: { sha: beforeSha, version: beforeVersion }, steps, durationMs: Date.now() - startedAt, }; } - const repairedUiIndexHealth = await resolveControlUiDistIndexHealth({ root: gitRoot }); - if (!repairedUiIndexHealth.exists) { - const uiIndexPath = - repairedUiIndexHealth.indexPath ?? resolveControlUiDistIndexPathForRoot(gitRoot); + const buildStep = await runStep( + step("build", managerScriptArgs(manager.manager, "build"), gitRoot, manager.env), + ); + steps.push(buildStep); + if (buildStep.exitCode !== 0) { + return { + status: "error", + mode: "git", + root: gitRoot, + reason: "build-failed", + before: { sha: beforeSha, version: beforeVersion }, + steps, + durationMs: Date.now() - startedAt, + }; + } + + const uiBuildStep = await runStep( + step("ui:build", managerScriptArgs(manager.manager, "ui:build"), gitRoot, manager.env), + ); + steps.push(uiBuildStep); + if (uiBuildStep.exitCode !== 0) { + return { + status: "error", + mode: "git", + root: gitRoot, + reason: "ui-build-failed", + before: { sha: beforeSha, version: beforeVersion }, + steps, + durationMs: Date.now() - startedAt, + }; + } + + const doctorEntry = path.join(gitRoot, "openclaw.mjs"); + const doctorEntryExists = await fs + .stat(doctorEntry) + .then(() => true) + .catch(() => false); + if (!doctorEntryExists) { steps.push({ - name: "ui assets verify", - command: `verify ${uiIndexPath}`, + name: "openclaw doctor entry", + command: `verify ${doctorEntry}`, cwd: gitRoot, durationMs: 0, exitCode: 1, - stderrTail: `missing ${uiIndexPath}`, + stderrTail: `missing ${doctorEntry}`, }); return { status: "error", mode: "git", root: gitRoot, - reason: "ui-assets-missing", + reason: "doctor-entry-missing", before: { sha: beforeSha, version: beforeVersion }, steps, durationMs: Date.now() - startedAt, }; } + + // Use --fix so that doctor auto-strips unknown config keys introduced by + // schema changes between versions, preventing a startup validation crash. + const doctorNodePath = await resolveStableNodePath(process.execPath); + const doctorArgv = [doctorNodePath, doctorEntry, "doctor", "--non-interactive", "--fix"]; + const doctorStep = await runStep( + step("openclaw doctor", doctorArgv, gitRoot, { OPENCLAW_UPDATE_IN_PROGRESS: "1" }), + ); + steps.push(doctorStep); + + const uiIndexHealth = await resolveControlUiDistIndexHealth({ root: gitRoot }); + if (!uiIndexHealth.exists) { + const repairArgv = managerScriptArgs(manager.manager, "ui:build"); + const started = Date.now(); + const repairResult = await runCommand(repairArgv, { + cwd: gitRoot, + timeoutMs, + env: manager.env, + }); + const repairStep: UpdateStepResult = { + name: "ui:build (post-doctor repair)", + command: repairArgv.join(" "), + cwd: gitRoot, + durationMs: Date.now() - started, + exitCode: repairResult.code, + stdoutTail: trimLogTail(repairResult.stdout, MAX_LOG_CHARS), + stderrTail: trimLogTail(repairResult.stderr, MAX_LOG_CHARS), + }; + steps.push(repairStep); + + if (repairResult.code !== 0) { + return { + status: "error", + mode: "git", + root: gitRoot, + reason: repairStep.name, + before: { sha: beforeSha, version: beforeVersion }, + steps, + durationMs: Date.now() - startedAt, + }; + } + + const repairedUiIndexHealth = await resolveControlUiDistIndexHealth({ root: gitRoot }); + if (!repairedUiIndexHealth.exists) { + const uiIndexPath = + repairedUiIndexHealth.indexPath ?? resolveControlUiDistIndexPathForRoot(gitRoot); + steps.push({ + name: "ui assets verify", + command: `verify ${uiIndexPath}`, + cwd: gitRoot, + durationMs: 0, + exitCode: 1, + stderrTail: `missing ${uiIndexPath}`, + }); + return { + status: "error", + mode: "git", + root: gitRoot, + reason: "ui-assets-missing", + before: { sha: beforeSha, version: beforeVersion }, + steps, + durationMs: Date.now() - startedAt, + }; + } + } + + const failedStep = steps.find((s) => s.exitCode !== 0); + const afterShaStep = await runStep( + step("git rev-parse HEAD (after)", ["git", "-C", gitRoot, "rev-parse", "HEAD"], gitRoot), + ); + steps.push(afterShaStep); + const afterVersion = await readPackageVersion(gitRoot); + + return { + status: failedStep ? "error" : "ok", + mode: "git", + root: gitRoot, + reason: failedStep ? failedStep.name : undefined, + before: { sha: beforeSha, version: beforeVersion }, + after: { + sha: afterShaStep.stdoutTail?.trim() ?? null, + version: afterVersion, + }, + steps, + durationMs: Date.now() - startedAt, + }; + } finally { + await manager.cleanup?.(); } - - const failedStep = steps.find((s) => s.exitCode !== 0); - const afterShaStep = await runStep( - step("git rev-parse HEAD (after)", ["git", "-C", gitRoot, "rev-parse", "HEAD"], gitRoot), - ); - steps.push(afterShaStep); - const afterVersion = await readPackageVersion(gitRoot); - - return { - status: failedStep ? "error" : "ok", - mode: "git", - root: gitRoot, - reason: failedStep ? failedStep.name : undefined, - before: { sha: beforeSha, version: beforeVersion }, - after: { - sha: afterShaStep.stdoutTail?.trim() ?? null, - version: afterVersion, - }, - steps, - durationMs: Date.now() - startedAt, - }; } if (!pkgRoot) {