diff --git a/.agents/skills/openclaw-release-maintainer/SKILL.md b/.agents/skills/openclaw-release-maintainer/SKILL.md index 9fa3c20baaa..0d7772fd567 100644 --- a/.agents/skills/openclaw-release-maintainer/SKILL.md +++ b/.agents/skills/openclaw-release-maintainer/SKILL.md @@ -86,6 +86,10 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts - For stable correction releases like `YYYY.M.D-N`, it also verifies the upgrade path from `YYYY.M.D` to `YYYY.M.D-N` so a correction publish cannot silently leave existing global installs on the old base stable payload. +- Treat install smoke as a pack-budget gate too. `pnpm test:install:smoke` + now fails the candidate update tarball when npm reports an oversized + `unpackedSize`, so release-time e2e cannot miss pack bloat that would risk + low-memory install/startup failures. ## Check all relevant release builds diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 85224dbaddb..42cc7cae500 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -90,6 +90,9 @@ OpenClaw has three public release lanes: - npm release preflight fails closed unless the tarball includes both `dist/control-ui/index.html` and a non-empty `dist/control-ui/assets/` payload so we do not ship an empty browser dashboard again +- `pnpm test:install:smoke` also enforces the npm pack `unpackedSize` budget on + the candidate update tarball, so installer e2e catches accidental pack bloat + before the release publish path - If the release work touched CI planning, extension timing manifests, or extension test matrices, regenerate and review the planner-owned `checks-node-extensions` workflow matrix outputs from `.github/workflows/ci.yml` diff --git a/scripts/lib/npm-pack-budget.d.mts b/scripts/lib/npm-pack-budget.d.mts new file mode 100644 index 00000000000..f38b9975be4 --- /dev/null +++ b/scripts/lib/npm-pack-budget.d.mts @@ -0,0 +1,22 @@ +export type NpmPackBudgetResult = { + filename?: string; + unpackedSize?: number; +}; + +export declare const NPM_PACK_UNPACKED_SIZE_BUDGET_BYTES: number; + +export declare function formatMiB(bytes: number): string; + +export declare function formatPackUnpackedSizeBudgetError(params: { + budgetBytes?: number; + label: string; + unpackedSize: number; +}): string; + +export declare function collectPackUnpackedSizeErrors( + results: Iterable, + options?: { + budgetBytes?: number; + missingDataMessage?: string; + }, +): string[]; diff --git a/scripts/lib/npm-pack-budget.mjs b/scripts/lib/npm-pack-budget.mjs new file mode 100644 index 00000000000..b1edd43de8a --- /dev/null +++ b/scripts/lib/npm-pack-budget.mjs @@ -0,0 +1,55 @@ +// 2026.3.12 ballooned to ~213.6 MiB unpacked and correlated with low-memory +// startup/doctor OOM reports. 2026.4.12 intentionally stages Matrix runtime +// dependencies, including crypto wasm, so packaged installs do not miss Docker +// and gateway runtime dependencies. Keep the budget below the 2026.3.12 bloat +// level while allowing that mirrored runtime surface. +export const NPM_PACK_UNPACKED_SIZE_BUDGET_BYTES = 202 * 1024 * 1024; + +export function formatMiB(bytes) { + return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`; +} + +function resolvePackResultLabel(entry, index) { + return entry.filename?.trim() || `pack result #${index + 1}`; +} + +export function formatPackUnpackedSizeBudgetError(params) { + const budgetBytes = params.budgetBytes ?? NPM_PACK_UNPACKED_SIZE_BUDGET_BYTES; + return [ + `${params.label} unpackedSize ${params.unpackedSize} bytes (${formatMiB(params.unpackedSize)}) exceeds budget ${budgetBytes} bytes (${formatMiB(budgetBytes)}).`, + "Investigate duplicate channel shims, copied extension trees, or other accidental pack bloat before release.", + ].join(" "); +} + +export function collectPackUnpackedSizeErrors(results, options = {}) { + const entries = Array.from(results); + const errors = []; + const budgetBytes = options.budgetBytes ?? NPM_PACK_UNPACKED_SIZE_BUDGET_BYTES; + 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 <= budgetBytes) { + continue; + } + errors.push( + formatPackUnpackedSizeBudgetError({ + budgetBytes, + label: resolvePackResultLabel(entry, index), + unpackedSize: entry.unpackedSize, + }), + ); + } + + if (entries.length > 0 && checkedCount === 0) { + errors.push( + options.missingDataMessage ?? + "npm pack --dry-run produced no unpackedSize data; pack size budget was not verified.", + ); + } + + return errors; +} diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 6cab2636e3f..1b69c4b5445 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -17,6 +17,7 @@ import { collectBundledPluginRuntimeDependencySpecs, collectRootDistBundledRuntimeMirrors, } from "./lib/bundled-plugin-root-runtime-mirrors.mjs"; +import { collectPackUnpackedSizeErrors as collectNpmPackUnpackedSizeErrors } from "./lib/npm-pack-budget.mjs"; import { listPluginSdkDistArtifacts } from "./lib/plugin-sdk-entries.mjs"; import { listStaticExtensionAssetOutputs } from "./runtime-postbuild.mjs"; import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./sparkle-build.ts"; @@ -53,12 +54,6 @@ const forbiddenPrefixes = [ "dist/plugin-sdk/.tsbuildinfo", "docs/.generated/", ]; -// 2026.3.12 ballooned to ~213.6 MiB unpacked and correlated with low-memory -// startup/doctor OOM reports. 2026.4.12 intentionally stages Matrix runtime -// dependencies, including crypto wasm, so packaged installs do not miss Docker -// and gateway runtime dependencies. Keep the budget below the 2026.3.12 bloat -// level while allowing that mirrored runtime surface. -const npmPackUnpackedSizeBudgetBytes = 202 * 1024 * 1024; const appcastPath = resolve("appcast.xml"); const laneBuildMin = 1_000_000_000; const laneFloorAdoptionDateKey = 20260227; @@ -269,49 +264,7 @@ export function collectForbiddenPackPaths(paths: Iterable): string[] { .toSorted((left, right) => left.localeCompare(right)); } -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; -} +export { collectPackUnpackedSizeErrors } from "./lib/npm-pack-budget.mjs"; function extractTag(item: string, tag: string): string | null { const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -486,7 +439,7 @@ async function main() { }) .toSorted((left, right) => left.localeCompare(right)); const forbidden = collectForbiddenPackPaths(paths); - const sizeErrors = collectPackUnpackedSizeErrors(results); + const sizeErrors = collectNpmPackUnpackedSizeErrors(results); if (missing.length > 0 || forbidden.length > 0 || sizeErrors.length > 0) { if (missing.length > 0) { diff --git a/scripts/test-install-sh-docker.sh b/scripts/test-install-sh-docker.sh index d74da025c40..19bf96520e4 100755 --- a/scripts/test-install-sh-docker.sh +++ b/scripts/test-install-sh-docker.sh @@ -58,6 +58,39 @@ console.log( ' "$label" "$pack_json_file" } +assert_pack_unpacked_size_budget() { + local label="$1" + local pack_json_file="$2" + node --input-type=module - "$label" "$pack_json_file" <<'NODE' +import { readFileSync } from "node:fs"; +import { collectPackUnpackedSizeErrors } from "./scripts/lib/npm-pack-budget.mjs"; + +const label = process.argv[2]; +const packJsonFile = process.argv[3]; +const raw = readFileSync(packJsonFile, "utf8") || "[]"; +const parsed = JSON.parse(raw); +const budgetOverride = process.env.OPENCLAW_INSTALL_SMOKE_PACK_UNPACKED_BUDGET_BYTES; +const budgetBytes = budgetOverride ? Number(budgetOverride) : undefined; +if (budgetOverride && !Number.isFinite(budgetBytes)) { + throw new Error( + `OPENCLAW_INSTALL_SMOKE_PACK_UNPACKED_BUDGET_BYTES must be numeric, got ${JSON.stringify( + budgetOverride, + )}`, + ); +} +const errors = collectPackUnpackedSizeErrors(parsed, { + budgetBytes, + missingDataMessage: `${label} npm pack output did not include unpackedSize; install smoke cannot verify pack budget.`, +}); +for (const error of errors) { + console.error(`ERROR: ${error}`); +} +if (errors.length > 0) { + process.exit(1); +} +NODE +} + print_pack_delta_audit() { local baseline_pack_json_file="$1" local update_pack_json_file="$2" @@ -191,6 +224,7 @@ process.stdout.write(last.filename); ' "$pack_json_file" )" print_pack_audit "update" "$pack_json_file" + assert_pack_unpacked_size_budget "update" "$pack_json_file" packed_update_version="$( node -e ' const raw = require("node:fs").readFileSync(process.argv[1], "utf8") || "[]"; diff --git a/test/scripts/test-install-sh-docker.test.ts b/test/scripts/test-install-sh-docker.test.ts index a99593573da..7fbe2a1ae3f 100644 --- a/test/scripts/test-install-sh-docker.test.ts +++ b/test/scripts/test-install-sh-docker.test.ts @@ -35,6 +35,15 @@ describe("test-install-sh-docker", () => { expect(script).toContain("==> Pack audit"); expect(script).toContain("==> Pack audit delta"); }); + + it("fails the update smoke when the candidate npm pack exceeds the release budget", () => { + const script = readFileSync(SCRIPT_PATH, "utf8"); + + expect(script).toContain("assert_pack_unpacked_size_budget"); + expect(script).toContain('assert_pack_unpacked_size_budget "update" "$pack_json_file"'); + expect(script).toContain('from "./scripts/lib/npm-pack-budget.mjs"'); + expect(script).toContain("install smoke cannot verify pack budget"); + }); }); describe("install-sh smoke runner", () => {