Skip to content

Commit c702719

Browse files
authored
fix: preserve Mac bundle during live builds (#104376)
1 parent cfda86b commit c702719

3 files changed

Lines changed: 93 additions & 2 deletions

File tree

.agents/skills/openclaw-live-updater/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Keep `/Users/steipete/openclaw` a read-only-to-the-agent deployment mirror: clea
3030
- Every successful update sets `actions.gatewayBuild` and rebuilds exact new `main` before any restart.
3131
- Missing, invalid, or stale build output also forces a build, even when Git did not move.
3232
- A dependency-input change, absent `node_modules`, or missing/invalid build provenance first runs `pnpm install --frozen-lockfile`.
33-
- Stop the managed Gateway immediately before `pnpm build` by invoking the existing `dist/index.js` directly; source launchers can auto-build stale output before dispatching the stop. Never mutate the live `dist` tree while an old Gateway can dynamically import from it. `pnpm build` must leave both canonical stamp heads and `dist/build-info.json.commit` equal to post-update `afterSha`; any missing/mismatched stamp or required artifact blocks restart.
33+
- Stop the managed Gateway immediately before `pnpm build` by invoking the existing `dist/index.js` directly; source launchers can auto-build stale output before dispatching the stop. Preserve `dist/OpenClaw.app` outside `dist` for the build and restore it even when the build fails, because the JS build cleans `dist` regardless of Mac impact classification. Never mutate the live `dist` tree while an old Gateway can dynamically import from it. `pnpm build` must leave both canonical stamp heads and `dist/build-info.json.commit` equal to post-update `afterSha`; any missing/mismatched stamp or required artifact blocks restart.
3434
- Only after exact-SHA build proof may it restart the managed Gateway and require `gateway status --deep --require-rpc --json` plus `health --verbose --json`.
3535
- After every managed restart, query Gateway logs through RPC, restrict the audit to entries emitted since that restart began, report warning summaries, and fail the pass on any error/fatal entry. If RPC verification or log retrieval fails, still inspect the local structured log for that restart window. Never accept supervisor or RPC health without this restart-window log audit.
3636

.agents/skills/openclaw-live-updater/scripts/update-main.mjs

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,45 @@ function assertExactBuild(checkout, expectedSha) {
522522
return state;
523523
}
524524

525+
function runBuildWithPreservedMacApp(runCommand, checkout) {
526+
const appBundle = path.join(checkout, "dist/OpenClaw.app");
527+
if (!existsSync(appBundle)) {
528+
runCommand("pnpm", ["build"], checkout);
529+
return;
530+
}
531+
const appStat = lstatSync(appBundle);
532+
if (!appStat.isDirectory() || appStat.isSymbolicLink()) {
533+
throw new UpdateInvariantError(
534+
"unsafe_mac_bundle",
535+
`refusing to preserve unsafe Mac app bundle: ${appBundle}`,
536+
);
537+
}
538+
const preservedBundle = path.join(
539+
checkout,
540+
".git",
541+
`.openclaw-live-mac-${process.pid}-${randomUUID()}.app`,
542+
);
543+
renameSync(appBundle, preservedBundle);
544+
try {
545+
runCommand("pnpm", ["build"], checkout);
546+
} finally {
547+
if (!existsSync(preservedBundle)) {
548+
throw new UpdateInvariantError(
549+
"missing_preserved_mac_bundle",
550+
`preserved Mac app bundle disappeared: ${preservedBundle}`,
551+
);
552+
}
553+
if (existsSync(appBundle)) {
554+
throw new UpdateInvariantError(
555+
"mac_bundle_restore_conflict",
556+
`build unexpectedly created ${appBundle}; preserved bundle remains at ${preservedBundle}`,
557+
);
558+
}
559+
mkdirSync(path.dirname(appBundle), { recursive: true });
560+
renameSync(preservedBundle, appBundle);
561+
}
562+
}
563+
525564
function restartGateway(runCommand, checkout, expectedSha) {
526565
assertExactBuild(checkout, expectedSha);
527566
const startedAtMs = Date.now();
@@ -750,7 +789,7 @@ export function maintainMain(options, dependencies = {}) {
750789
// Use the existing built CLI directly. Source launchers may auto-build a
751790
// stale dist before dispatching `gateway stop`, recreating the live-import race.
752791
runCommand(process.execPath, ["dist/index.js", "gateway", "stop"], update.checkout);
753-
runCommand("pnpm", ["build"], update.checkout);
792+
runBuildWithPreservedMacApp(runCommand, update.checkout);
754793
assertExactBuild(update.checkout, update.afterSha);
755794
const restartStartedAt = restartGateway(runCommand, update.checkout, update.afterSha);
756795
gatewayLogAudit = verifyAndAuditGateway({

test/scripts/openclaw-live-updater.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
mkdirSync,
66
mkdtempSync,
77
readFileSync,
8+
readdirSync,
89
realpathSync,
910
renameSync,
1011
rmSync,
@@ -426,6 +427,57 @@ describe("openclaw live updater", () => {
426427
]);
427428
});
428429

430+
test("preserves the signed Mac bundle while a Gateway build replaces dist", () => {
431+
const { root, mirror } = makeFixture();
432+
mkdirSync(path.join(mirror, "node_modules"));
433+
const appBundle = path.join(mirror, "dist/OpenClaw.app");
434+
const appMarker = path.join(appBundle, "Contents/signature-marker");
435+
mkdirSync(path.dirname(appMarker), { recursive: true });
436+
writeFileSync(appMarker, "signed\n");
437+
const commands = fakeCommands(mirror);
438+
439+
maintainFixture(
440+
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
441+
{
442+
runCommand(command: string, args: string[]) {
443+
if (command === "pnpm" && args[0] === "build") {
444+
expect(existsSync(appBundle)).toBe(false);
445+
}
446+
commands.runCommand(command, args);
447+
},
448+
},
449+
);
450+
451+
expect(readFileSync(appMarker, "utf8")).toBe("signed\n");
452+
expect(
453+
readdirSync(path.join(mirror, ".git")).filter((entry) =>
454+
entry.startsWith(".openclaw-live-mac-"),
455+
),
456+
).toEqual([]);
457+
});
458+
459+
test("restores the Mac bundle when the Gateway build fails", () => {
460+
const { root, mirror } = makeFixture();
461+
mkdirSync(path.join(mirror, "node_modules"));
462+
const appMarker = path.join(mirror, "dist/OpenClaw.app/Contents/signature-marker");
463+
mkdirSync(path.dirname(appMarker), { recursive: true });
464+
writeFileSync(appMarker, "signed\n");
465+
466+
expect(() =>
467+
maintainFixture(
468+
{ checkout: mirror, remote: "origin", lockPath: path.join(root, "maintenance.lock") },
469+
{
470+
runCommand(command: string, args: string[]) {
471+
if (command === "pnpm" && args[0] === "build") {
472+
throw new Error("build failed");
473+
}
474+
},
475+
},
476+
),
477+
).toThrow("build failed");
478+
expect(readFileSync(appMarker, "utf8")).toBe("signed\n");
479+
});
480+
429481
test("proves a current exact-SHA Gateway on a no-op heartbeat", () => {
430482
const { root, mirror } = makeFixture();
431483
mkdirSync(path.join(mirror, "node_modules"));

0 commit comments

Comments
 (0)