mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
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:
parent
aa6008ad19
commit
8f98c12c58
6 changed files with 313 additions and 1 deletions
6
.github/workflows/package-acceptance.yml
vendored
6
.github/workflows/package-acceptance.yml
vendored
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
158
scripts/check-openclaw-installed-package-budget.mts
Normal file
158
scripts/check-openclaw-installed-package-budget.mts
Normal 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("&", "&")
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
106
test/scripts/check-openclaw-installed-package-budget.test.ts
Normal file
106
test/scripts/check-openclaw-installed-package-budget.test.ts
Normal 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue