fix(update): roll back failed git updates

This commit is contained in:
Vincent Koc
2026-05-23 00:44:59 +08:00
parent 9f1472ed8f
commit 769fd0b14a
3 changed files with 79 additions and 75 deletions

View File

@@ -80,6 +80,7 @@ Docs: https://docs.openclaw.ai
- Plugins/discovery: strip `-plugin` package suffixes when deriving plugin id hints so package names line up with manifest ids. (#85170) Thanks @JulyanXu.
- Tlon: stop advertising a non-existent agent tool contract in the plugin manifest.
- Telegram: preserve fenced code block languages through Markdown rendering so Telegram receives `language-*` code classes. (#85209) Thanks @leno23.
- Windows updates: roll back git-backed updates to the previous checkout when dependency install, build, UI build, or doctor repair fails.
- Windows installer: persist user-local portable Git on PATH and activate the repo-pinned pnpm version for git-backed installs and updates.
- Windows installer: bootstrap a user-local portable Node.js when native Windows has no Node and no winget, Chocolatey, or Scoop, so first-run installs can continue on raw hosts.
- Windows installer: extract the downloaded portable Node.js directory with native `tar` before falling back to .NET zip extraction, avoiding PowerShell 5.1 archive and path-length failures.

View File

@@ -420,6 +420,7 @@ describe("runGatewayUpdate", () => {
const stableTag = "v1.0.1-1";
const { runner, calls } = createRunner({
...buildStableTagResponses(stableTag),
[`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" },
"pnpm install": { code: 1, stderr: "ERR_PNPM_NETWORK" },
});
@@ -429,6 +430,12 @@ describe("runGatewayUpdate", () => {
expect(result.reason).toBe("deps-install-failed");
expect(calls).not.toContain("pnpm build");
expect(calls).not.toContain("pnpm ui:build");
expect(calls).toContain(`git -C ${tempDir} reset --hard`);
expect(calls).toContain(`git -C ${tempDir} checkout --force main`);
expect(calls).toContain(`git -C ${tempDir} reset --hard abc123`);
expect(calls.indexOf(`git -C ${tempDir} reset --hard`)).toBeLessThan(
calls.indexOf(`git -C ${tempDir} checkout --force main`),
);
});
it("uses pnpm highest resolution mode for update installs", async () => {
@@ -591,6 +598,7 @@ describe("runGatewayUpdate", () => {
const stableTag = "v1.0.1-1";
const { runner, calls } = createRunner({
...buildStableTagResponses(stableTag),
[`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" },
"pnpm install": { stdout: "" },
"pnpm build": { code: 1, stderr: "tsc: error TS2345" },
});
@@ -601,6 +609,9 @@ describe("runGatewayUpdate", () => {
expect(result.reason).toBe("build-failed");
expect(calls).toContain("pnpm install");
expect(calls).not.toContain("pnpm ui:build");
expect(calls).toContain(`git -C ${tempDir} reset --hard`);
expect(calls).toContain(`git -C ${tempDir} checkout --force main`);
expect(calls).toContain(`git -C ${tempDir} reset --hard abc123`);
});
it("uses stable tag when beta tag is older than release", async () => {
@@ -2175,7 +2186,8 @@ describe("runGatewayUpdate", () => {
expect(result.status).toBe("error");
expect(result.reason).toBe("doctor-entry-missing");
expect(result.steps.at(-1)?.name).toBe("openclaw doctor entry");
expect(result.steps.some((step) => step.name === "openclaw doctor entry")).toBe(true);
expect(result.steps.at(-1)?.name).toMatch(/^git rollback/);
});
it("repairs UI assets when doctor run removes control-ui files", async () => {
@@ -2222,5 +2234,6 @@ describe("runGatewayUpdate", () => {
expect(result.status).toBe("error");
expect(result.reason).toBe("ui-assets-missing");
expect(result.steps.at(-1)?.name).toMatch(/^git rollback/);
});
});

View File

@@ -161,7 +161,7 @@ export type UpdateInstallSurface =
function mapManagerResolutionFailure(
reason: UpdatePackageManagerFailureReason,
): UpdateRunResult["reason"] {
): NonNullable<UpdateRunResult["reason"]> {
return reason;
}
@@ -766,7 +766,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
const beforeVersion = await readPackageVersion(gitRoot);
const channel: UpdateChannel = opts.channel ?? "dev";
const devTargetRef = channel === "dev" ? normalizeDevTargetRef(opts.devTargetRef) : null;
const branch = channel === "dev" ? await readBranchName(runCommand, gitRoot, timeoutMs) : null;
const branch = await readBranchName(runCommand, gitRoot, timeoutMs);
const needsCheckoutMain = channel === "dev" && !devTargetRef && branch !== DEV_BRANCH;
gitTotalSteps = channel === "dev" ? (needsCheckoutMain ? 11 : 10) : 9;
const buildGitErrorResult = (reason: string): UpdateRunResult => ({
@@ -786,6 +786,60 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
}
return null;
};
const appendRecoveryStep = async (name: string, argv: string[]) => {
const started = Date.now();
const result = await runCommand(argv, { cwd: gitRoot, timeoutMs });
const recoveryStep: UpdateStepResult = {
name,
command: argv.join(" "),
cwd: gitRoot,
durationMs: Date.now() - started,
exitCode: result.code,
stdoutTail: trimLogTail(result.stdout, MAX_LOG_CHARS),
stderrTail: trimLogTail(result.stderr, MAX_LOG_CHARS),
};
steps.push(recoveryStep);
return recoveryStep.exitCode === 0;
};
const rollbackGitCheckout = async () => {
if (!beforeSha) {
return;
}
await appendRecoveryStep("git rollback clean", ["git", "-C", gitRoot, "reset", "--hard"]);
if (branch && branch !== "HEAD") {
const checkedOutBranch = await appendRecoveryStep("git rollback checkout", [
"git",
"-C",
gitRoot,
"checkout",
"--force",
branch,
]);
if (checkedOutBranch) {
await appendRecoveryStep("git rollback reset", [
"git",
"-C",
gitRoot,
"reset",
"--hard",
beforeSha,
]);
}
return;
}
await appendRecoveryStep("git rollback checkout", [
"git",
"-C",
gitRoot,
"checkout",
"--detach",
beforeSha,
]);
};
const buildGitErrorResultWithRollback = async (reason: string): Promise<UpdateRunResult> => {
await rollbackGitCheckout();
return buildGitErrorResult(reason);
};
const statusCheck = await runStep(
step(
@@ -1212,15 +1266,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
"require-preferred",
);
if (manager.kind === "missing-required") {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: mapManagerResolutionFailure(manager.reason),
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
return await buildGitErrorResultWithRollback(mapManagerResolutionFailure(manager.reason));
}
try {
const installEnv = resolveInstallEnv(manager.manager, manager.env);
@@ -1247,15 +1293,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
}
}
if (finalDepsStep.exitCode !== 0) {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "deps-install-failed",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
return await buildGitErrorResultWithRollback("deps-install-failed");
}
const buildStep = await runStep(
@@ -1268,15 +1306,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
);
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,
};
return await buildGitErrorResultWithRollback("build-failed");
}
const uiBuildStep = await runStep(
@@ -1284,15 +1314,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
);
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,
};
return await buildGitErrorResultWithRollback("ui-build-failed");
}
const doctorEntry = path.join(gitRoot, "openclaw.mjs");
@@ -1309,15 +1331,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
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,
};
return await buildGitErrorResultWithRollback("doctor-entry-missing");
}
// Use --fix so that doctor auto-strips unknown config keys introduced by
@@ -1335,15 +1349,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
);
steps.push(doctorStep);
if (doctorStep.exitCode !== 0) {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "doctor-failed",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
return await buildGitErrorResultWithRollback("doctor-failed");
}
const uiIndexHealth = await resolveControlUiDistIndexHealth({ root: gitRoot });
@@ -1367,15 +1373,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
steps.push(repairStep);
if (repairResult.code !== 0) {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "ui-build-failed",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
return await buildGitErrorResultWithRollback("ui-build-failed");
}
const repairedUiIndexHealth = await resolveControlUiDistIndexHealth({ root: gitRoot });
@@ -1390,15 +1388,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
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,
};
return await buildGitErrorResultWithRollback("ui-assets-missing");
}
}