diff --git a/.github/workflows/package-acceptance.yml b/.github/workflows/package-acceptance.yml index f796655a54e9..06e7ea0008bb 100644 --- a/.github/workflows/package-acceptance.yml +++ b/.github/workflows/package-acceptance.yml @@ -1314,6 +1314,12 @@ jobs: [[ ! -e "$pending" ]] [[ ! -e "$legacy_guard" ]] + - name: Check installed package tree budget + shell: bash + run: | + set -euo pipefail + node scripts/check-openclaw-installed-package-budget.mts "$RUNNER_TEMP/openclaw-npm12-prefix/lib/node_modules/openclaw" + docker_acceptance: name: Docker product acceptance (artifact-only) needs: [resolve_package, package_integrity] diff --git a/docs/ci/release-validation/package-acceptance.md b/docs/ci/release-validation/package-acceptance.md index 5a98590b1ffc..5b30ad5fb65c 100644 --- a/docs/ci/release-validation/package-acceptance.md +++ b/docs/ci/release-validation/package-acceptance.md @@ -17,11 +17,40 @@ Use `Package Acceptance` when the question is "does this installable OpenClaw pa 1. `resolve_package` checks out `workflow_ref`, resolves one package candidate, writes `.artifacts/docker-e2e-package/openclaw-current.tgz`, writes `.artifacts/docker-e2e-package/package-candidate.json`, uploads both as the `package-under-test` artifact, and prints the source, workflow ref, package ref, version, SHA-256, and profile in the GitHub step summary. 2. `package_integrity` downloads the `package-under-test` artifact and enforces the public package tarball contract with `scripts/check-openclaw-package-tarball.mjs`. -3. `npm_12_install_sh` installs that exact artifact through the public Linux installer under npm 12 in an isolated home/prefix, then verifies the CLI version and lifecycle-completion guard. +3. `npm_12_install_sh` installs that exact artifact through the public Linux installer under npm 12 in an isolated home/prefix, then verifies the CLI version and lifecycle-completion guard and enforces the installed-package tree budget. 4. `docker_acceptance` calls `openclaw-live-and-e2e-checks-reusable.yml` with the resolved package source SHA (falling back to `workflow_ref`) and `package_artifact_name=package-under-test`. The reusable workflow downloads that artifact, validates the tarball inventory, prepares package-digest Docker images when needed, and runs the selected Docker lanes against that package instead of packing the workflow checkout. When a profile selects multiple targeted `docker_lanes`, the reusable workflow prepares the package and shared images once, then fans those lanes out as parallel targeted Docker jobs with unique artifacts. 5. `package_telegram` optionally calls `NPM Telegram Beta E2E`. It runs when `telegram_mode` is not `none` and installs the same `package-under-test` artifact when Package Acceptance resolved one; standalone Telegram dispatch can still install a published npm spec. 6. `summary` fails the workflow if package resolution, integrity, npm 12 installer acceptance, Docker acceptance, or the optional Telegram lane failed. Selected lanes keep their first failure; callers cannot downgrade a failing test to a warning. +### Installed package tree budget + +`scripts/check-openclaw-installed-package-budget.mts` measures the npm-installed +package tree, including dependencies that the tarball check cannot see. It counts +the root and every directory entry without following symlinks, and sums every +regular file's size, including npm's hidden `node_modules/.package-lock.json` +files, because published updaters charge those bytes too. Hardlinked paths count +separately. The report lists the largest contributors by entry count and adds the +totals to the GitHub step summary. + +Published updaters freeze caps of **50,000 entries / 1 GiB**. The release budgets +are **47,500 entries / 900 MiB**: the 2,500-entry reserve (5%) covers npm-version, +hoisting, and per-platform optional-dependency variance and is larger than routine +dependency bumps, so the check fails while every shipped updater can still install +the candidate. Bytes are far from the cap but vary more with platform native +prebuilds, so the byte budget keeps roughly 12% in reserve. + +To reproduce locally with a candidate tarball: + +```bash +tmp="$(mktemp -d)" +npm install -g --prefix "$tmp" ./openclaw.tgz +node scripts/check-openclaw-installed-package-budget.mts "$tmp/lib/node_modules/openclaw" +``` + +On failure, reduce installed entries by trimming the largest dependencies or +dist chunk and precompressed asset counts; reduce large files for a byte-budget +failure. Do not raise the budgets: caps cannot change in already-shipped updaters. + ### Candidate sources - `source=npm` accepts only `openclaw@extended-stable`, `openclaw@beta`, `openclaw@latest`, or an exact OpenClaw release version such as `openclaw@2026.9.5`. Use this for published extended-stable, prerelease, or stable acceptance. diff --git a/scripts/check-openclaw-installed-package-budget.mts b/scripts/check-openclaw-installed-package-budget.mts new file mode 100644 index 000000000000..e14214b9ebd3 --- /dev/null +++ b/scripts/check-openclaw-installed-package-budget.mts @@ -0,0 +1,158 @@ +import { appendFile, lstat, readFile, readdir } from "node:fs/promises"; +import path from "node:path"; +import { isDirectRunUrl } from "./lib/direct-run.mjs"; + +// These caps and counting rules are frozen in already-published updaters +// (2026.9.3-2026.9.7), which cannot be patched. They charge every regular file's +// bytes, including npm's hidden node_modules/.package-lock.json; later readers +// that skip it are more lenient. Do not follow src/infra/package-update-integrity.ts. +const SHIPPED_DRIVER_CAP = { entries: 50_000, bytes: 1024 * 1024 * 1024 }; +const INSTALLED_PACKAGE_BUDGET = { entries: 47_500, bytes: 900 * 1024 * 1024 }; + +type TreeSize = { entries: number; bytes: number }; +type Contributor = TreeSize & { bucket: string }; +type Measurement = TreeSize & { name: string; version: string; contributors: Contributor[] }; + +class UsageError extends Error {} + +function contributorBucket(relative: string, isDirectory: boolean): string { + const segments = relative.split("/"); + if (segments[0] === "node_modules") { + return segments.slice(0, segments[1]?.startsWith("@") ? 3 : 2).join("/"); + } + if (segments[0] === "dist" && segments.length > 1) { + return segments.length > 2 || isDirectory ? `dist/${segments[1]}` : "dist/*"; + } + return segments[0] ?? ""; +} + +export async function measureInstalledPackageTree(root: string): Promise { + const packageRoot = path.resolve(root); + let manifest: unknown; + try { + if (!(await lstat(packageRoot)).isDirectory()) { + throw new Error("root must be a real directory, not a symlink or file"); + } + manifest = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8")); + if ( + !manifest || + typeof manifest !== "object" || + !("version" in manifest) || + typeof manifest.version !== "string" + ) { + throw new Error("package.json must contain a string version"); + } + } catch (error) { + throw new UsageError( + `Expected an installed OpenClaw package root such as /lib/node_modules/openclaw with a package.json containing a string version: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + const measurement: Measurement = { + name: "name" in manifest && typeof manifest.name === "string" ? manifest.name : "openclaw", + version: manifest.version, + entries: 1, + bytes: 0, + contributors: [], + }; + const contributors = new Map(); + + // Serial awaits cost one threadpool round trip per entry (minutes on a loaded + // host); totals are order-independent, so let libuv overlap the whole walk. + async function walk(directory: string, relativeDirectory: string): Promise { + const children = await readdir(directory, { withFileTypes: true }); + await Promise.all( + children.map(async (child) => { + const relative = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; + const file = path.join(directory, child.name); + let bytes = 0; + if (child.isFile()) { + const stat = await lstat(file); + if (stat.isFile()) { + bytes = stat.size; + } + } + measurement.entries++; + measurement.bytes += bytes; + const bucket = contributorBucket(relative, child.isDirectory()); + const contributor = contributors.get(bucket) ?? { bucket, entries: 0, bytes: 0 }; + contributor.entries++; + contributor.bytes += bytes; + contributors.set(bucket, contributor); + if (child.isDirectory()) { + await walk(file, relative); + } + }), + ); + } + + await walk(packageRoot, ""); + measurement.contributors = [...contributors.values()].toSorted( + (left, right) => right.entries - left.entries || left.bucket.localeCompare(right.bucket, "en"), + ); + return measurement; +} + +export function evaluateInstalledPackageBudget( + measurement: TreeSize, + budget: TreeSize = INSTALLED_PACKAGE_BUDGET, +): Array { + const dimensions: Array = ["entries", "bytes"]; + return dimensions.filter((dimension) => measurement[dimension] > budget[dimension]); +} + +function formatEntries(entries: number): string { + return entries.toLocaleString("en-US"); +} + +function formatBytes(bytes: number): string { + return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`; +} + +async function main(): Promise { + const root = process.argv[2]; + if (process.argv.length !== 3 || root === undefined) { + console.error("Usage: node scripts/check-openclaw-installed-package-budget.mts "); + return 2; + } + const started = performance.now(); + const measurement = await measureInstalledPackageTree(root); + const durationMs = performance.now() - started; + const identity = `${measurement.name}@${measurement.version}`.replace(/[\r\n]/gu, " "); + const summary = `Installed ${identity} tree: ${formatEntries(measurement.entries)} entries (budget ${formatEntries(INSTALLED_PACKAGE_BUDGET.entries)}; shipped updater cap ${formatEntries(SHIPPED_DRIVER_CAP.entries)}), ${formatBytes(measurement.bytes)} (budget ${formatBytes(INSTALLED_PACKAGE_BUDGET.bytes)}; cap ${formatBytes(SHIPPED_DRIVER_CAP.bytes)})`; + console.log(summary); + for (const contributor of measurement.contributors.slice(0, 10)) { + console.log( + ` ${contributor.bucket}: ${formatEntries(contributor.entries)} entries, ${formatBytes(contributor.bytes)}`, + ); + } + console.log(`Walk duration: ${durationMs.toFixed(1)} ms`); + if (process.env.GITHUB_STEP_SUMMARY) { + const markdownSummary = summary + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replace(/[\\`*_{}[\]()#+.!|]/gu, "\\$&"); + await appendFile(process.env.GITHUB_STEP_SUMMARY, `${markdownSummary}\n`); + } + const exceeded = evaluateInstalledPackageBudget(measurement); + for (const dimension of exceeded) { + const format = dimension === "entries" ? formatEntries : formatBytes; + const unit = dimension === "entries" ? " entries" : ""; + const prefix = + process.env.GITHUB_ACTIONS === "true" ? "::error title=Installed package tree budget::" : ""; + console.error( + `${prefix}Installed package tree has ${format(measurement[dimension])}${unit}, over the ${format(INSTALLED_PACKAGE_BUDGET[dimension])} budget (shipped updaters refuse trees over ${format(SHIPPED_DRIVER_CAP[dimension])}). Trim dependencies or dist output before release; see docs/ci/release-validation/package-acceptance.md#installed-package-tree-budget.`, + ); + } + return exceeded.length ? 1 : 0; +} + +if (isDirectRunUrl(process.argv[1], import.meta.url)) { + try { + process.exitCode = await main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = error instanceof UsageError ? 2 : 1; + } +} diff --git a/scripts/lib/ci-proof-test-inventory.mts b/scripts/lib/ci-proof-test-inventory.mts index 08469b030f0f..b8ed84e7f03c 100644 --- a/scripts/lib/ci-proof-test-inventory.mts +++ b/scripts/lib/ci-proof-test-inventory.mts @@ -3864,6 +3864,7 @@ export const PR_PROTECTED_RUNTIME_TEST_FILES: readonly string[] = [ "test/scripts/check-cli-bootstrap-imports.test.ts", "test/scripts/check-extension-package-tsc-boundary.test.ts", "test/scripts/check-max-lines-ratchet.test.ts", + "test/scripts/check-openclaw-installed-package-budget.test.ts", "test/scripts/check-openclaw-package-tarball-control-ui.test.ts", "test/scripts/check-openclaw-package-tarball.bundled-mcp.test.ts", "test/scripts/check-openclaw-package-tarball.test.ts", diff --git a/test/scripts/check-openclaw-installed-package-budget.test.ts b/test/scripts/check-openclaw-installed-package-budget.test.ts new file mode 100644 index 000000000000..246627f1fe5e --- /dev/null +++ b/test/scripts/check-openclaw-installed-package-budget.test.ts @@ -0,0 +1,106 @@ +import { linkSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + evaluateInstalledPackageBudget, + measureInstalledPackageTree, +} from "../../scripts/check-openclaw-installed-package-budget.mts"; +import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +describe("installed package tree budget", () => { + // Published updaters (2026.9.3-2026.9.7) charge npm's hidden lockfile bytes too. + it("counts installed paths and every regular file's bytes without following links", async () => { + const root = tempDirs.make("openclaw-installed-budget-"); + const outside = tempDirs.make("openclaw-installed-budget-external-"); + writeFileSync(path.join(outside, "not-in-package"), "outside bytes"); + const manifest = '{"name":"openclaw","version":"1.2.3"}'; + writeFileSync(path.join(root, "package.json"), manifest); + const files = [ + [".package-lock.json", 7], + ["docs/readme.md", 11], + ["dist/chunks/a.js", 13], + ["dist/runtime.js", 17], + ["node_modules/.package-lock.json", 100], + ["node_modules/x/index.js", 19], + ["node_modules/x/node_modules/.package-lock.json", 101], + ["node_modules/@scope/pkg/index.js", 29], + ["node_modules/@scope/pkg/node_modules/y/index.js", 23], + ] as const; + for (const [relative, bytes] of files) { + const file = path.join(root, relative); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, "x".repeat(bytes)); + } + mkdirSync(path.join(root, "node_modules/.bin")); + symlinkSync(outside, path.join(root, "dist/external"), "junction"); + symlinkSync(path.join(outside, "not-in-package"), path.join(root, "node_modules/.bin/tool")); + linkSync(path.join(root, ".package-lock.json"), path.join(root, "docs/copy")); + + const measurement = await measureInstalledPackageTree(root); + expect(measurement).toEqual({ + name: "openclaw", + version: "1.2.3", + entries: 25, + bytes: Buffer.byteLength(manifest) + 327, + // Container directories own one entry; package buckets include their nested dependencies. + // dist itself owns one entry, while direct files and links share dist/*. + contributors: [ + { bucket: "node_modules/@scope/pkg", entries: 5, bytes: 52 }, + { bucket: "node_modules/x", entries: 4, bytes: 120 }, + { bucket: "docs", entries: 3, bytes: 18 }, + { bucket: "dist/*", entries: 2, bytes: 17 }, + { bucket: "dist/chunks", entries: 2, bytes: 13 }, + { bucket: "node_modules/.bin", entries: 2, bytes: 0 }, + { bucket: ".package-lock.json", entries: 1, bytes: 7 }, + { bucket: "dist", entries: 1, bytes: 0 }, + { bucket: "node_modules", entries: 1, bytes: 0 }, + { bucket: "node_modules/.package-lock.json", entries: 1, bytes: 100 }, + { bucket: "node_modules/@scope", entries: 1, bytes: 0 }, + { bucket: "package.json", entries: 1, bytes: Buffer.byteLength(manifest) }, + ], + }); + }); + + it.each([undefined, "{", '{"version":123}'])( + "rejects an invalid manifest: %s", + async (manifest) => { + const root = tempDirs.make("openclaw-installed-budget-invalid-"); + if (manifest !== undefined) { + writeFileSync(path.join(root, "package.json"), manifest); + } + await expect(measureInstalledPackageTree(root)).rejects.toThrow( + "an installed OpenClaw package root such as /lib/node_modules/openclaw", + ); + }, + ); + + it("rejects a file or symlink root, including a trailing separator", async () => { + const root = tempDirs.make("openclaw-installed-budget-root-"); + const manifest = path.join(root, "package.json"); + writeFileSync(manifest, '{"version":"1.2.3"}'); + const link = path.join(tempDirs.make("openclaw-installed-budget-link-"), "package"); + symlinkSync(root, link, "junction"); + for (const invalidRoot of [manifest, link, `${link}${path.sep}`]) { + await expect(measureInstalledPackageTree(invalidRoot)).rejects.toThrow( + "root must be a real directory", + ); + } + }); + + it.each([ + { entries: 4, bytes: 9, exceeded: [] }, + { entries: 5, bytes: 10, exceeded: [] }, + { entries: 6, bytes: 10, exceeded: ["entries"] }, + { entries: 5, bytes: 11, exceeded: ["bytes"] }, + { entries: 6, bytes: 11, exceeded: ["entries", "bytes"] }, + ])( + "evaluates $entries entries and $bytes bytes against inclusive budgets", + ({ entries, bytes, exceeded }) => { + expect(evaluateInstalledPackageBudget({ entries, bytes }, { entries: 5, bytes: 10 })).toEqual( + exceeded, + ); + }, + ); +}); diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 174778d50309..861c9a940889 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -8381,6 +8381,18 @@ test "$package_manager" = "pnpm@12.1.0" expect(JSON.stringify(npm12Job)).not.toContain("secrets."); }); + it("checks the installed package tree budget immediately after npm 12 installation", () => { + const job = workflowJob(PACKAGE_ACCEPTANCE_WORKFLOW, "npm_12_install_sh"); + const install = workflowStep(job, "Run install.sh with npm 12"); + const budget = workflowStep(job, "Check installed package tree budget"); + const steps = job.steps ?? []; + expect(steps.indexOf(budget)).toBe(steps.indexOf(install) + 1); + expect(budget.shell).toBe("bash"); + expect(budget.run).toBe( + 'set -euo pipefail\nnode scripts/check-openclaw-installed-package-budget.mts "$RUNNER_TEMP/openclaw-npm12-prefix/lib/node_modules/openclaw"\n', + ); + }); + it("binds npm 12 installation to the supplied prerelease dependency artifact", () => { const job = workflowJob(PACKAGE_ACCEPTANCE_WORKFLOW, "npm_12_install_sh"); const validate = workflowStep(job, "Validate prerelease plugin registry artifact identity");