Skip to content

Commit 781c9b7

Browse files
committed
fix(release): harden package update validation
1 parent dda2cf4 commit 781c9b7

3 files changed

Lines changed: 83 additions & 8 deletions

File tree

scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,6 @@ function assertExpectedDiagnostics(surfaceMode, errorMessages) {
142142
"cli registration missing explicit commands metadata",
143143
"only bundled plugins can register Codex app-server extension factories",
144144
"only bundled plugins can register agent tool result middleware",
145-
"agent event subscription registration requires id and handle",
146145
'compaction provider "kitchen-sink-compaction-provider" registration missing summarize',
147146
"context engine registration missing id",
148147
"control UI descriptor registration requires id, surface, label, and valid optional fields",
@@ -158,6 +157,10 @@ function assertExpectedDiagnostics(surfaceMode, errorMessages) {
158157
"session scheduler job registration requires unique id, sessionKey, and kind",
159158
"tool metadata registration missing toolName",
160159
]);
160+
const optionalErrorMessages = new Set([
161+
"agent event subscription registration requires id and handle",
162+
]);
163+
const allowedErrorMessages = new Set([...expectedErrorMessages, ...optionalErrorMessages]);
161164
if (!INVALID_PROBE_DIAGNOSTIC_SURFACE_MODES.has(surfaceMode)) {
162165
if (errorMessages.size > 0) {
163166
throw new Error(
@@ -167,7 +170,7 @@ function assertExpectedDiagnostics(surfaceMode, errorMessages) {
167170
return;
168171
}
169172
for (const message of errorMessages) {
170-
if (!expectedErrorMessages.has(message)) {
173+
if (!allowedErrorMessages.has(message)) {
171174
throw new Error(`unexpected kitchen-sink diagnostic error: ${message}`);
172175
}
173176
}

src/infra/package-update-steps.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,67 @@ describe("runGlobalPackageUpdateSteps", () => {
115115
});
116116
});
117117

118+
it("keeps a successful staged swap when old package cleanup hits a transient Windows native module error", async () => {
119+
await withTempDir({ prefix: "openclaw-package-update-staged-cleanup-" }, async (base) => {
120+
const prefix = path.join(base, "prefix");
121+
const globalRoot = path.join(prefix, "lib", "node_modules");
122+
const packageRoot = path.join(globalRoot, "openclaw");
123+
await writePackageRoot(packageRoot, "1.0.0");
124+
125+
const realRm = fs.rm;
126+
const rmSpy = vi.spyOn(fs, "rm").mockImplementation(async (target, options) => {
127+
const targetPath = String(target);
128+
if (
129+
targetPath.includes(`${path.sep}.openclaw-`) &&
130+
!targetPath.includes(".openclaw-update-stage-") &&
131+
!targetPath.includes(".openclaw-shim-backup-")
132+
) {
133+
throw Object.assign(new Error("EPERM: operation not permitted, unlink native.node"), {
134+
code: "EPERM",
135+
});
136+
}
137+
return realRm(target, options);
138+
});
139+
140+
try {
141+
const result = await runGlobalPackageUpdateSteps({
142+
installTarget: createNpmTarget(globalRoot),
143+
installSpec: "openclaw@2.0.0",
144+
packageName: "openclaw",
145+
packageRoot,
146+
runCommand: createRootRunner(globalRoot),
147+
runStep: async ({ name, argv, cwd }) => {
148+
const prefixIndex = argv.indexOf("--prefix");
149+
const stagePrefix = argv[prefixIndex + 1];
150+
if (!stagePrefix) {
151+
throw new Error("missing staged prefix");
152+
}
153+
await writePackageRoot(
154+
path.join(stagePrefix, "lib", "node_modules", "openclaw"),
155+
"2.0.0",
156+
);
157+
return {
158+
name,
159+
command: argv.join(" "),
160+
cwd: cwd ?? process.cwd(),
161+
durationMs: 1,
162+
exitCode: 0,
163+
};
164+
},
165+
timeoutMs: 1000,
166+
});
167+
168+
expect(result.failedStep).toBeNull();
169+
expect(result.afterVersion).toBe("2.0.0");
170+
await expect(
171+
fs.readFile(path.join(packageRoot, "package.json"), "utf8"),
172+
).resolves.toContain('"version":"2.0.0"');
173+
} finally {
174+
rmSpy.mockRestore();
175+
}
176+
});
177+
});
178+
118179
it("does not run post-verify work when staged npm verification fails", async () => {
119180
await withTempDir({ prefix: "openclaw-package-update-verify-" }, async (base) => {
120181
const prefix = path.join(base, "prefix");

src/infra/package-update-steps.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,17 @@ async function pathExists(targetPath: string): Promise<boolean> {
6060
}
6161
}
6262

63+
async function removePathBestEffort(targetPath: string): Promise<void> {
64+
await fs
65+
.rm(targetPath, {
66+
recursive: true,
67+
force: true,
68+
maxRetries: process.platform === "win32" ? 5 : 2,
69+
retryDelay: 100,
70+
})
71+
.catch(() => undefined);
72+
}
73+
6374
async function readPackageVersionIfPresent(packageRoot: string | null): Promise<string | null> {
6475
if (!packageRoot) {
6576
return null;
@@ -129,12 +140,12 @@ async function cleanupStagedNpmInstall(stage: StagedNpmInstall | null): Promise<
129140
if (!stage) {
130141
return;
131142
}
132-
await fs.rm(stage.prefix, { recursive: true, force: true }).catch(() => undefined);
143+
await removePathBestEffort(stage.prefix);
133144
}
134145

135146
async function copyPathEntry(source: string, destination: string): Promise<void> {
136147
const stat = await fs.lstat(source);
137-
await fs.rm(destination, { recursive: true, force: true }).catch(() => undefined);
148+
await removePathBestEffort(destination);
138149
if (stat.isSymbolicLink()) {
139150
await fs.symlink(await fs.readlink(source), destination);
140151
return;
@@ -201,15 +212,15 @@ async function replaceNpmBinShims(params: {
201212
await restoreNpmBinShimBackup(backup);
202213
throw err;
203214
} finally {
204-
await fs.rm(backup.backupDir, { recursive: true, force: true }).catch(() => undefined);
215+
await removePathBestEffort(backup.backupDir);
205216
}
206217
}
207218

208219
async function restoreNpmBinShimBackup(backup: NpmBinShimBackup): Promise<void> {
209220
await fs.mkdir(backup.targetBinDir, { recursive: true });
210221
for (const entry of backup.entries) {
211222
const destination = path.join(backup.targetBinDir, entry.name);
212-
await fs.rm(destination, { recursive: true, force: true }).catch(() => undefined);
223+
await removePathBestEffort(destination);
213224
if (entry.hadExisting) {
214225
await copyPathEntry(path.join(backup.backupDir, entry.name), destination);
215226
}
@@ -253,7 +264,7 @@ async function swapStagedNpmInstall(params: {
253264
packageName: params.packageName,
254265
});
255266
if (movedExisting) {
256-
await fs.rm(backupRoot, { recursive: true, force: true });
267+
await removePathBestEffort(backupRoot);
257268
}
258269
return {
259270
name: "global install swap",
@@ -268,7 +279,7 @@ async function swapStagedNpmInstall(params: {
268279
};
269280
} catch (err) {
270281
if (movedStaged) {
271-
await fs.rm(targetPackageRoot, { recursive: true, force: true }).catch(() => undefined);
282+
await removePathBestEffort(targetPackageRoot);
272283
}
273284
if (movedExisting) {
274285
await fs.rename(backupRoot, targetPackageRoot).catch(() => undefined);

0 commit comments

Comments
 (0)