From 07f890fa45bc782fab594fdfc77505aca6ea4c4b Mon Sep 17 00:00:00 2001 From: Ted Li Date: Sun, 15 Mar 2026 13:31:30 -0700 Subject: [PATCH] fix(release): block oversized npm packs that regress low-memory startup (#46850) * fix(release): guard npm pack size regressions * fix(release): fail closed when npm omits pack size --- scripts/release-check.ts | 59 ++++++++++++++++++++++++++++++++++++-- test/release-check.test.ts | 32 +++++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 6f621cef2d5..34d37634d6f 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -15,7 +15,7 @@ import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./s export { collectBundledExtensionManifestErrors } from "./lib/bundled-extension-manifest.ts"; type PackFile = { path: string }; -type PackResult = { files?: PackFile[] }; +type PackResult = { files?: PackFile[]; filename?: string; unpackedSize?: number }; const requiredPathGroups = [ ["dist/index.js", "dist/index.mjs"], @@ -112,6 +112,10 @@ const requiredPathGroups = [ "dist/build-info.json", ]; const forbiddenPrefixes = ["dist/OpenClaw.app/"]; +// 2026.3.12 ballooned to ~213.6 MiB unpacked and correlated with low-memory +// startup/doctor OOM reports. Keep enough headroom for the current pack while +// failing fast if duplicate/shim content sneaks back into the release artifact. +const npmPackUnpackedSizeBudgetBytes = 160 * 1024 * 1024; const appcastPath = resolve("appcast.xml"); const laneBuildMin = 1_000_000_000; const laneFloorAdoptionDateKey = 20260227; @@ -228,6 +232,50 @@ export function collectForbiddenPackPaths(paths: Iterable): string[] { .toSorted(); } +function formatMiB(bytes: number): string { + return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`; +} + +function resolvePackResultLabel(entry: PackResult, index: number): string { + return entry.filename?.trim() || `pack result #${index + 1}`; +} + +function formatPackUnpackedSizeBudgetError(params: { + label: string; + unpackedSize: number; +}): string { + return [ + `${params.label} unpackedSize ${params.unpackedSize} bytes (${formatMiB(params.unpackedSize)}) exceeds budget ${npmPackUnpackedSizeBudgetBytes} bytes (${formatMiB(npmPackUnpackedSizeBudgetBytes)}).`, + "Investigate duplicate channel shims, copied extension trees, or other accidental pack bloat before release.", + ].join(" "); +} + +export function collectPackUnpackedSizeErrors(results: Iterable): string[] { + const entries = Array.from(results); + const errors: string[] = []; + let checkedCount = 0; + + for (const [index, entry] of entries.entries()) { + if (typeof entry.unpackedSize !== "number" || !Number.isFinite(entry.unpackedSize)) { + continue; + } + checkedCount += 1; + if (entry.unpackedSize <= npmPackUnpackedSizeBudgetBytes) { + continue; + } + const label = resolvePackResultLabel(entry, index); + errors.push(formatPackUnpackedSizeBudgetError({ label, unpackedSize: entry.unpackedSize })); + } + + if (entries.length > 0 && checkedCount === 0) { + errors.push( + "npm pack --dry-run produced no unpackedSize data; pack size budget was not verified.", + ); + } + + return errors; +} + function checkPluginVersions() { const rootPackagePath = resolve("package.json"); const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8")) as PackageJson; @@ -433,8 +481,9 @@ function main() { }) .toSorted(); const forbidden = collectForbiddenPackPaths(paths); + const sizeErrors = collectPackUnpackedSizeErrors(results); - if (missing.length > 0 || forbidden.length > 0) { + if (missing.length > 0 || forbidden.length > 0 || sizeErrors.length > 0) { if (missing.length > 0) { console.error("release-check: missing files in npm pack:"); for (const path of missing) { @@ -447,6 +496,12 @@ function main() { console.error(` - ${path}`); } } + if (sizeErrors.length > 0) { + console.error("release-check: npm pack unpacked size budget exceeded:"); + for (const error of sizeErrors) { + console.error(` - ${error}`); + } + } process.exit(1); } diff --git a/test/release-check.test.ts b/test/release-check.test.ts index a399407aa98..5f0bcf65192 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -4,12 +4,17 @@ import { collectBundledExtensionManifestErrors, collectBundledExtensionRootDependencyGapErrors, collectForbiddenPackPaths, + collectPackUnpackedSizeErrors, } from "../scripts/release-check.ts"; function makeItem(shortVersion: string, sparkleVersion: string): string { return `${shortVersion}${shortVersion}${sparkleVersion}`; } +function makePackResult(filename: string, unpackedSize: number) { + return { filename, unpackedSize }; +} + describe("collectAppcastSparkleVersionErrors", () => { it("accepts legacy 9-digit calver builds before lane-floor cutover", () => { const xml = `${makeItem("2026.2.26", "202602260")}`; @@ -163,3 +168,30 @@ describe("collectForbiddenPackPaths", () => { ).toEqual(["extensions/tlon/node_modules/.bin/tlon", "node_modules/.bin/openclaw"]); }); }); + +describe("collectPackUnpackedSizeErrors", () => { + it("accepts pack results within the unpacked size budget", () => { + expect( + collectPackUnpackedSizeErrors([makePackResult("openclaw-2026.3.14.tgz", 120_354_302)]), + ).toEqual([]); + }); + + it("flags oversized pack results that risk low-memory startup failures", () => { + expect( + collectPackUnpackedSizeErrors([makePackResult("openclaw-2026.3.12.tgz", 224_002_564)]), + ).toEqual([ + "openclaw-2026.3.12.tgz unpackedSize 224002564 bytes (213.6 MiB) exceeds budget 167772160 bytes (160.0 MiB). Investigate duplicate channel shims, copied extension trees, or other accidental pack bloat before release.", + ]); + }); + + it("fails closed when npm pack output omits unpackedSize for every result", () => { + expect( + collectPackUnpackedSizeErrors([ + { filename: "openclaw-2026.3.14.tgz" }, + { filename: "openclaw-extra.tgz", unpackedSize: Number.NaN }, + ]), + ).toEqual([ + "npm pack --dry-run produced no unpackedSize data; pack size budget was not verified.", + ]); + }); +});