chore(release): fail package acceptance before the installed tree outgrows shipped updaters (#163612)

* ci(release): guard installed package tree against shipped updater caps

Published updaters (2026.9.7 and current main) fingerprint the whole
npm-installed package tree with createPackageIntegrityReader().tree(),
capped at 50,000 entries (root included) and 1 GiB. The staged-candidate
walk in preparePackageActivation does not tolerate the limit error, and
installed updaters cannot be patched, so a candidate whose installed tree
crosses the cap cannot be installed by those drivers. The tarball check
only sees the ~13.9k tarball entries, not the installed dependency tree.

Package Acceptance's npm_12_install_sh job already installs the exact
candidate globally; a new step measures that install with the reader's
counting rules (root plus every dirent, no symlink following, regular-file
bytes excluding npm's hidden lockfile) and fails above 47,500 entries or
900 MiB, while every shipped updater can still install the candidate.
It runs wherever Package Acceptance runs (release checks, the 3-hourly
main Full Release Validation, weekly Update Migration) at the cost of one
directory walk; no new job or install.

Measured: main candidate 44,074 entries / 562.0 MiB; published 2026.9.7
44,098 / 630.3 MiB, both matching an independent find oracle. The real
reader accepts a 50,000-entry tree the guard counts as 50,000 and refuses
the 50,001-entry tree.

* fix(release): charge hidden lockfile bytes like published updaters

Published updaters 2026.9.3 through 2026.9.7 add every regular file's
bytes to the 1 GiB rollback-verification cap, including npm's hidden
node_modules/.package-lock.json. Only main (#162365, unreleased) skips
those bytes, and the guard had copied main's more lenient rule, so it
could pass a tree that every shipped updater refuses. Count every
regular file so the guard never undercounts relative to any shipped
driver. Entry counting is identical across all shipped tags.

Real global installs write no hidden lockfiles: published 2026.9.7
still measures 44,098 entries / 630.3 MiB, matching an all-files find.
This commit is contained in:
Peter Steinberger 2026-10-02 10:49:02 -07:00 • committed by GitHub
parent aa6008ad19
commit 8f98c12c58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 313 additions and 1 deletions

View file

@ -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]

View file

@ -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.

View file

@ -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<Measurement> {
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 <prefix>/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<string, Contributor>();
// 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<void> {
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<keyof TreeSize> {
const dimensions: Array<keyof TreeSize> = ["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<number> {
const root = process.argv[2];
if (process.argv.length !== 3 || root === undefined) {
console.error("Usage: node scripts/check-openclaw-installed-package-budget.mts <root>");
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.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;
}
}

View file

@ -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",

View file

@ -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 <prefix>/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,
);
},
);
});

View file

@ -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");