diff --git a/docs/ci.md b/docs/ci.md index 18e00422acb..2d572d78a74 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -95,6 +95,20 @@ Cross-OS release checks still cover OS-specific onboarding, installer, and platform behavior; package/update product validation should start with Package Acceptance. +Package Acceptance has a bounded legacy-compatibility window for already +published packages through `2026.4.25`, including `2026.4.25-beta.*`. Those +allowances are documented here so they do not become permanent silent skips: +known private QA entries in `dist/postinstall-inventory.json` may warn when the +tarball omitted those files; `doctor-switch` may skip the +`gateway install --wrapper` persistence subcase when the package does not expose +that flag; `update-channel-switch` may prune missing `pnpm.patchedDependencies` +from the tarball-derived fake git fixture and may log missing persisted +`update.channel`; plugin smokes may read legacy install-record locations or +accept missing marketplace install-record persistence; and `plugin-update` may +allow config metadata migration while still requiring the install record and +no-reinstall behavior to stay unchanged. Packages after `2026.4.25` must satisfy +the modern contracts; the same conditions fail instead of warn or skip. + Examples: ```bash diff --git a/docs/help/testing.md b/docs/help/testing.md index 1afc7811add..04ce503b6c2 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -657,6 +657,7 @@ These Docker runners split into two buckets: explicitly want the larger exhaustive scan. - `test:docker:all` builds the live Docker image once via `test:docker:live-build`, packs OpenClaw once as an npm tarball through `scripts/package-openclaw-for-docker.mjs`, then builds/reuses two `scripts/e2e/Dockerfile` images. The bare image is only the Node/Git runner for install/update/plugin-dependency lanes; those lanes mount the prebuilt tarball. The functional image installs the same tarball into `/app` for built-app functionality lanes. Docker lane definitions live in `scripts/lib/docker-e2e-scenarios.mjs`; planner logic lives in `scripts/lib/docker-e2e-plan.mjs`; `scripts/test-docker-all.mjs` executes the selected plan. The aggregate uses a weighted local scheduler: `OPENCLAW_DOCKER_ALL_PARALLELISM` controls process slots, while resource caps keep heavy live, npm-install, and multi-service lanes from all starting at once. If a single lane is heavier than the active caps, the scheduler can still start it when the pool is empty and then keeps it running alone until capacity is available again. Defaults are 10 slots, `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=9`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=10`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7`; tune `OPENCLAW_DOCKER_ALL_WEIGHT_LIMIT` or `OPENCLAW_DOCKER_ALL_DOCKER_LIMIT` only when the Docker host has more headroom. The runner performs a Docker preflight by default, removes stale OpenClaw E2E containers, prints status every 30 seconds, stores successful lane timings in `.artifacts/docker-tests/lane-timings.json`, and uses those timings to start longer lanes first on later runs. Use `OPENCLAW_DOCKER_ALL_DRY_RUN=1` to print the weighted lane manifest without building or running Docker, or `node scripts/test-docker-all.mjs --plan-json` to print the CI plan for selected lanes, package/image needs, and credentials. - `Package Acceptance` is the GitHub-native package gate for "does this installable tarball work as a product?" It resolves one candidate package from `source=npm`, `source=ref`, `source=url`, or `source=artifact`, uploads it as `package-under-test`, then runs the reusable Docker E2E lanes against that exact tarball instead of repacking the selected ref. `workflow_ref` selects the trusted workflow/harness scripts, while `package_ref` selects the source commit/branch/tag to pack when `source=ref`; this lets current acceptance logic validate older trusted commits. Profiles are ordered by breadth: `smoke` is quick install/channel/agent plus gateway/config, `package` is the package/update/plugin contract and the default native replacement for most Parallels package/update coverage, `product` adds MCP channels, cron/subagent cleanup, OpenAI web search, and OpenWebUI, and `full` runs the release-path Docker chunks with OpenWebUI. Release validation runs the `package` profile for the target ref with Telegram package QA enabled. Targeted GitHub Docker rerun commands generated from artifacts include prior package artifact and prepared image inputs when available, so failed lanes can avoid rebuilding the package and images. +- Package Acceptance legacy compatibility is capped at `2026.4.25` (`2026.4.25-beta.*` included). Through that cutoff, the harness tolerates only shipped-package metadata gaps: omitted private QA inventory entries, missing `gateway install --wrapper`, missing patch files in the tarball-derived git fixture, missing persisted `update.channel`, legacy plugin install-record locations, missing marketplace install-record persistence, and config metadata migration during `plugins update`. For packages after `2026.4.25`, those paths are strict failures. - Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:npm-onboard-channel-agent`, `test:docker:update-channel-switch`, `test:docker:session-runtime-context`, `test:docker:agents-delete-shared-workspace`, `test:docker:gateway-network`, `test:docker:browser-cdp-snapshot`, `test:docker:mcp-channels`, `test:docker:pi-bundle-mcp-tools`, `test:docker:cron-mcp-cleanup`, `test:docker:plugins`, `test:docker:plugin-update`, and `test:docker:config-reload` boot one or more real containers and verify higher-level integration paths. The live-model Docker runners also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store: diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 266e70f2839..f9c841a3a31 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -379,6 +379,15 @@ release checks still matter for OS-specific onboarding, installer, and platform behavior, but package/update product validation should prefer Package Acceptance. +Legacy package-acceptance leniency is intentionally time boxed. Packages through +`2026.4.25` may use the compatibility path for metadata gaps already published +to npm: private QA inventory entries missing from the tarball, missing +`gateway install --wrapper`, missing patch files in the tarball-derived git +fixture, missing persisted `update.channel`, legacy plugin install-record +locations, missing marketplace install-record persistence, and config metadata +migration during `plugins update`. Packages after `2026.4.25` must satisfy the +modern package contracts; those same gaps fail release validation. + Use broader Package Acceptance profiles when the release question is about an actual installable package: diff --git a/scripts/check-openclaw-package-tarball.mjs b/scripts/check-openclaw-package-tarball.mjs index 7a54fd9dff6..d0a08146cf8 100644 --- a/scripts/check-openclaw-package-tarball.mjs +++ b/scripts/check-openclaw-package-tarball.mjs @@ -38,6 +38,7 @@ const normalized = entries.map((entry) => entry.replace(/^package\//u, "")); const entrySet = new Set(normalized); const errors = []; const warnings = []; +const LEGACY_PACKAGE_ACCEPTANCE_COMPAT_MAX = { year: 2026, month: 4, day: 25 }; const LEGACY_OMITTED_PRIVATE_QA_INVENTORY_PREFIXES = [ "dist/extensions/qa-channel/", @@ -68,6 +69,32 @@ function isLegacyOmittedPrivateQaInventoryEntry(relativePath) { ); } +function parseCalver(version) { + const match = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:[-+].*)?$/u.exec(version); + if (!match) { + return null; + } + return { + year: Number(match[1]), + month: Number(match[2]), + day: Number(match[3]), + }; +} + +function compareCalver(left, right) { + for (const key of ["year", "month", "day"]) { + if (left[key] !== right[key]) { + return left[key] - right[key]; + } + } + return 0; +} + +function isLegacyPackageAcceptanceCompatVersion(version) { + const parsed = parseCalver(version); + return parsed ? compareCalver(parsed, LEGACY_PACKAGE_ACCEPTANCE_COMPAT_MAX) <= 0 : false; +} + function readTarEntry(entryPath) { const candidates = [entryPath, `package/${entryPath}`]; for (const candidate of candidates) { @@ -99,6 +126,10 @@ if (!entrySet.has("dist/postinstall-inventory.json")) { } if (entrySet.has("dist/postinstall-inventory.json")) { try { + const packageJson = JSON.parse(readTarEntry("package.json")); + const packageVersion = typeof packageJson.version === "string" ? packageJson.version : ""; + const allowLegacyPrivateQaInventoryOmissions = + isLegacyPackageAcceptanceCompatVersion(packageVersion); const inventory = JSON.parse(readTarEntry("dist/postinstall-inventory.json")); if (!Array.isArray(inventory) || inventory.some((entry) => typeof entry !== "string")) { errors.push("invalid dist/postinstall-inventory.json"); @@ -106,7 +137,10 @@ if (entrySet.has("dist/postinstall-inventory.json")) { for (const inventoryEntry of inventory) { const normalizedEntry = inventoryEntry.replace(/\\/gu, "/"); if (!entrySet.has(normalizedEntry)) { - if (isLegacyOmittedPrivateQaInventoryEntry(normalizedEntry)) { + if ( + allowLegacyPrivateQaInventoryOmissions && + isLegacyOmittedPrivateQaInventoryEntry(normalizedEntry) + ) { warnings.push( `legacy inventory references omitted private QA tar entry ${normalizedEntry}`, ); diff --git a/scripts/e2e/doctor-install-switch-docker.sh b/scripts/e2e/doctor-install-switch-docker.sh index 0d5f8d48236..7f6fa2b1795 100755 --- a/scripts/e2e/doctor-install-switch-docker.sh +++ b/scripts/e2e/doctor-install-switch-docker.sh @@ -120,6 +120,22 @@ LOGINCTL fi git_cli="$git_root/openclaw.mjs" + package_version="$(node -p "require(\"$npm_root/package.json\").version")" + is_legacy_package_acceptance_compat() { + node - "$1" <<"NODE" +const version = process.argv[2] || ""; +const match = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:[-+].*)?$/.exec(version); +if (!match) process.exit(1); +const value = [Number(match[1]), Number(match[2]), Number(match[3])]; +const max = [2026, 4, 25]; +for (let i = 0; i < value.length; i += 1) { + if (value[i] < max[i]) process.exit(0); + if (value[i] > max[i]) process.exit(1); +} +process.exit(0); +NODE + } + assert_entrypoint() { local unit_path="$1" local expected="$2" @@ -314,7 +330,11 @@ WRAPPER if "$npm_bin" gateway install --help 2>&1 | grep -q -- "--wrapper"; then run_wrapper_flow - else + elif is_legacy_package_acceptance_compat "$package_version"; then + # Legacy compatibility: 2026.4.25 and older did not ship gateway install --wrapper. echo "Skipping wrapper persistence; package gateway install does not support --wrapper." + else + echo "Package $package_version must support gateway install --wrapper." >&2 + exit 1 fi ' diff --git a/scripts/e2e/plugin-update-unchanged-docker.sh b/scripts/e2e/plugin-update-unchanged-docker.sh index b22dc282eeb..25e2ca49e26 100755 --- a/scripts/e2e/plugin-update-unchanged-docker.sh +++ b/scripts/e2e/plugin-update-unchanged-docker.sh @@ -27,6 +27,9 @@ package_tgz=\"\${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_ npm install -g --prefix /tmp/npm-prefix \"\$package_tgz\" --no-fund --no-audit >/tmp/openclaw-install.log 2>&1 entry=\"/tmp/npm-prefix/lib/node_modules/openclaw/dist/index.mjs\" [ -f \"\$entry\" ] || entry=/tmp/npm-prefix/lib/node_modules/openclaw/dist/index.js +package_version=\$(node -p \"require('/tmp/npm-prefix/lib/node_modules/openclaw/package.json').version\") +OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT=\$(PACKAGE_VERSION=\"\$package_version\" node -e 'const version = process.env.PACKAGE_VERSION || \"\"; const match = new RegExp(\"^(\\\\d{4})\\\\.(\\\\d{1,2})\\\\.(\\\\d{1,2})(?:[-+].*)?\").exec(version); if (!match) { console.log(\"0\"); process.exit(0); } const value = [Number(match[1]), Number(match[2]), Number(match[3])]; const max = [2026, 4, 25]; for (let i = 0; i < value.length; i += 1) { if (value[i] < max[i]) { console.log(\"1\"); process.exit(0); } if (value[i] > max[i]) { console.log(\"0\"); process.exit(0); } } console.log(\"1\");') +export OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT export NPM_CONFIG_REGISTRY=http://127.0.0.1:4873 export PATH=\"/tmp/npm-prefix/bin:\$PATH\" @@ -37,7 +40,8 @@ cat > \"\$HOME/.openclaw/extensions/lossless-claw/package.json\" <<'JSON' \"version\": \"0.9.0\" } JSON -cat > \"\$HOME/.openclaw/openclaw.json\" <<'JSON' +if [ \"\$OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT\" = \"1\" ]; then + cat > \"\$HOME/.openclaw/openclaw.json\" <<'JSON' { \"plugins\": { \"installs\": { @@ -55,6 +59,13 @@ cat > \"\$HOME/.openclaw/openclaw.json\" <<'JSON' } } JSON +else + cat > \"\$HOME/.openclaw/openclaw.json\" <<'JSON' +{ + \"plugins\": {} +} +JSON +fi mkdir -p \"\$HOME/.openclaw/plugins\" cat > \"\$HOME/.openclaw/plugins/installs.json\" <<'JSON' { @@ -141,6 +152,11 @@ if [ \"\$registry_ready\" -ne 1 ]; then exit 1 fi +before_config_hash=\"\" +if [ \"\$OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT\" != \"1\" ]; then + before_config_hash=\$(sha256sum \"\$HOME/.openclaw/openclaw.json\" | awk '{print \$1}') +fi + node --input-type=module > /tmp/plugin-update-before.json <<'NODE' import fs from \"node:fs\"; import os from \"node:os\"; @@ -175,6 +191,15 @@ NODE node \"\$entry\" plugins update @example/lossless-claw > /tmp/plugin-update-output.log 2>&1 +if [ -n \"\$before_config_hash\" ]; then + after_config_hash=\$(sha256sum \"\$HOME/.openclaw/openclaw.json\" | awk '{print \$1}') + if [ \"\$before_config_hash\" != \"\$after_config_hash\" ]; then + echo \"Config changed unexpectedly for modern package \$package_version\" + cat /tmp/plugin-update-output.log + exit 1 + fi +fi + node --input-type=module <<'NODE' import fs from \"node:fs\"; import os from \"node:os\"; diff --git a/scripts/e2e/plugins-docker.sh b/scripts/e2e/plugins-docker.sh index a92a58bf63d..896b91d3414 100755 --- a/scripts/e2e/plugins-docker.sh +++ b/scripts/e2e/plugins-docker.sh @@ -38,6 +38,31 @@ else exit 1 fi export OPENCLAW_ENTRY +PACKAGE_VERSION="$(node -p 'require("./package.json").version')" +OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT="$( + node - "$PACKAGE_VERSION" <<'NODE' +const version = process.argv[2] || ""; +const match = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:[-+].*)?$/.exec(version); +if (!match) { + console.log("0"); + process.exit(0); +} +const value = [Number(match[1]), Number(match[2]), Number(match[3])]; +const max = [2026, 4, 25]; +for (let i = 0; i < value.length; i += 1) { + if (value[i] < max[i]) { + console.log("1"); + process.exit(0); + } + if (value[i] > max[i]) { + console.log("0"); + process.exit(0); + } +} +console.log("1"); +NODE +)" +export OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT home_dir=$(mktemp -d "/tmp/openclaw-plugins-e2e.XXXXXX") export HOME="$home_dir" @@ -562,12 +587,21 @@ const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs. const index = JSON.parse(fs.readFileSync(indexPath, "utf8")); const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; -const installRecords = index.installRecords ?? index.records ?? config.plugins?.installs ?? {}; +const allowLegacyCompat = process.env.OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT === "1"; +if (!allowLegacyCompat && !index.installRecords) { + throw new Error("expected modern installRecords in installed plugin index"); +} +const installRecords = allowLegacyCompat + ? index.installRecords ?? index.records ?? config.plugins?.installs ?? {} + : index.installRecords ?? {}; for (const id of ["marketplace-shortcut", "marketplace-direct"]) { const record = installRecords[id]; if (!record) { - console.log(`legacy package did not persist marketplace install record for ${id}`); - continue; + if (allowLegacyCompat) { + console.log(`legacy package did not persist marketplace install record for ${id}`); + continue; + } + throw new Error(`missing marketplace install record for ${id}`); } if (record.source !== "marketplace") { throw new Error(`unexpected source for ${id}: ${record.source}`); @@ -853,7 +887,13 @@ const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs. const index = JSON.parse(fs.readFileSync(indexPath, "utf8")); const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; -const installRecords = index.installRecords ?? index.records ?? config.plugins?.installs ?? {}; +const allowLegacyCompat = process.env.OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT === "1"; +if (!allowLegacyCompat && !index.installRecords) { + throw new Error("expected modern installRecords in installed plugin index"); +} +const installRecords = allowLegacyCompat + ? index.installRecords ?? index.records ?? config.plugins?.installs ?? {} + : index.installRecords ?? {}; const record = installRecords[pluginId]; if (!record) throw new Error(`missing ClawHub install record for ${pluginId}`); if (record.source !== "clawhub") { diff --git a/scripts/e2e/update-channel-switch-docker.sh b/scripts/e2e/update-channel-switch-docker.sh index 35ee02d7476..c5a6c7499db 100755 --- a/scripts/e2e/update-channel-switch-docker.sh +++ b/scripts/e2e/update-channel-switch-docker.sh @@ -48,6 +48,17 @@ const fs = require("node:fs"); const path = require("node:path"); const packageJsonPath = "/tmp/openclaw-git/package.json"; const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); +const isLegacyPackageAcceptanceCompat = (version) => { + const match = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:[-+].*)?$/.exec(version || ""); + if (!match) return false; + const value = [Number(match[1]), Number(match[2]), Number(match[3])]; + const max = [2026, 4, 25]; + for (let i = 0; i < value.length; i += 1) { + if (value[i] < max[i]) return true; + if (value[i] > max[i]) return false; + } + return true; +}; const fixtureUiBuildSource = `const fs=require("node:fs");fs.mkdirSync("dist/control-ui",{recursive:true});fs.writeFileSync("dist/control-ui/index.html","fixture\\n")`; const fixtureUiBuildCommand = `node -e ${JSON.stringify(fixtureUiBuildSource)}`; const nextPnpm = { ...packageJson.pnpm, allowUnusedPatches: true }; @@ -57,14 +68,28 @@ if ( typeof patchedDependencies === "object" && !Array.isArray(patchedDependencies) ) { + const patchEntries = Object.entries(patchedDependencies); const keptPatches = Object.fromEntries( - Object.entries(patchedDependencies).filter(([, patchFile]) => { + patchEntries.filter(([, patchFile]) => { return ( typeof patchFile === "string" && fs.existsSync(path.resolve(path.dirname(packageJsonPath), patchFile)) ); }), ); + const missingPatches = patchEntries.filter(([dependency, patchFile]) => { + return ( + typeof patchFile !== "string" || + !fs.existsSync(path.resolve(path.dirname(packageJsonPath), patchFile)) + ); + }); + if (missingPatches.length > 0 && !isLegacyPackageAcceptanceCompat(packageJson.version)) { + throw new Error( + `package ${packageJson.version} has missing pnpm.patchedDependencies in package fixture: ${missingPatches + .map(([dependency, patchFile]) => `${dependency} -> ${patchFile}`) + .join(", ")}`, + ); + } if (Object.keys(keptPatches).length > 0) { nextPnpm.patchedDependencies = keptPatches; } else { @@ -105,6 +130,31 @@ fixture_sha="$(git -C "$git_root" rev-parse HEAD)" pkg_tgz_path="$package_tgz" npm install -g --prefix /tmp/npm-prefix --omit=optional "$pkg_tgz_path" +package_version="$(node -p "require('/tmp/npm-prefix/lib/node_modules/openclaw/package.json').version")" +OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT="$( + node - "$package_version" <<"NODE" +const version = process.argv[2] || ""; +const match = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:[-+].*)?$/.exec(version); +if (!match) { + console.log("0"); + process.exit(0); +} +const value = [Number(match[1]), Number(match[2]), Number(match[3])]; +const max = [2026, 4, 25]; +for (let i = 0; i < value.length; i += 1) { + if (value[i] < max[i]) { + console.log("1"); + process.exit(0); + } + if (value[i] > max[i]) { + console.log("0"); + process.exit(0); + } +} +console.log("1"); +NODE +)" +export OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT home_dir="$(mktemp -d /tmp/openclaw-update-channel-switch-home.XXXXXX)" export HOME="$home_dir" @@ -149,7 +199,11 @@ const path = require("node:path"); const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); const config = JSON.parse(fs.readFileSync(configPath, "utf8")); if (config.update?.channel !== "dev") { - console.log(`legacy package did not persist update.channel dev; got ${JSON.stringify(config.update?.channel)}`); + if (process.env.OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT === "1") { + console.log(`legacy package did not persist update.channel dev; got ${JSON.stringify(config.update?.channel)}`); + } else { + throw new Error(`expected persisted update.channel dev, got ${JSON.stringify(config.update?.channel)}`); + } } NODE @@ -190,7 +244,11 @@ const path = require("node:path"); const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); const config = JSON.parse(fs.readFileSync(configPath, "utf8")); if (config.update?.channel !== "stable") { - console.log(`legacy package did not persist update.channel stable; got ${JSON.stringify(config.update?.channel)}`); + if (process.env.OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT === "1") { + console.log(`legacy package did not persist update.channel stable; got ${JSON.stringify(config.update?.channel)}`); + } else { + throw new Error(`expected persisted update.channel stable, got ${JSON.stringify(config.update?.channel)}`); + } } NODE diff --git a/test/scripts/check-openclaw-package-tarball.test.ts b/test/scripts/check-openclaw-package-tarball.test.ts index 5d1e987d010..3dc28e74dce 100644 --- a/test/scripts/check-openclaw-package-tarball.test.ts +++ b/test/scripts/check-openclaw-package-tarball.test.ts @@ -10,15 +10,13 @@ function withTarball( inventory: string[], files: Record, testBody: (tarball: string) => void, + version = "0.0.0", ) { const root = mkdtempSync(join(tmpdir(), "openclaw-package-tarball-test-")); try { const packageRoot = join(root, "package"); mkdirSync(join(packageRoot, "dist"), { recursive: true }); - writeFileSync( - join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "0.0.0" }), - ); + writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ name: "openclaw", version })); writeFileSync( join(packageRoot, "dist", "postinstall-inventory.json"), JSON.stringify(inventory), @@ -41,7 +39,7 @@ function withTarball( } describe("check-openclaw-package-tarball", () => { - it("allows legacy private QA inventory entries omitted from shipped tarballs", () => { + it("allows legacy private QA inventory entries omitted from shipped tarballs through 2026.4.25", () => { withTarball( ["dist/index.js", "dist/extensions/qa-channel/runtime-api.js"], { "dist/index.js": "export {};\n" }, @@ -52,6 +50,24 @@ describe("check-openclaw-package-tarball", () => { expect(result.stderr).toContain("legacy inventory references omitted private QA"); expect(result.stdout).toContain("OpenClaw package tarball integrity passed."); }, + "2026.4.25-beta.10", + ); + }); + + it("rejects legacy private QA inventory omissions for newer packages", () => { + withTarball( + ["dist/index.js", "dist/extensions/qa-channel/runtime-api.js"], + { "dist/index.js": "export {};\n" }, + (tarball) => { + const result = spawnSync("node", [CHECK_SCRIPT, tarball], { encoding: "utf8" }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + "inventory references missing tar entry dist/extensions/qa-channel/runtime-api.js", + ); + expect(result.stderr).not.toContain("legacy inventory references omitted private QA"); + }, + "2026.4.26", ); }); diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index f6a2d033801..e526b07ebfa 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -8,6 +8,8 @@ const INSTALL_E2E_RUNNER_PATH = "scripts/docker/install-sh-e2e/run.sh"; const OPENAI_WEB_SEARCH_MINIMAL_E2E_PATH = "scripts/e2e/openai-web-search-minimal-docker.sh"; const PLUGINS_DOCKER_E2E_PATH = "scripts/e2e/plugins-docker.sh"; const PLUGIN_UPDATE_DOCKER_E2E_PATH = "scripts/e2e/plugin-update-unchanged-docker.sh"; +const DOCTOR_SWITCH_DOCKER_E2E_PATH = "scripts/e2e/doctor-install-switch-docker.sh"; +const UPDATE_CHANNEL_SWITCH_DOCKER_E2E_PATH = "scripts/e2e/update-channel-switch-docker.sh"; const CENTRALIZED_BUILD_SCRIPTS = [ "scripts/docker/setup.sh", "scripts/e2e/browser-cdp-snapshot-docker.sh", @@ -75,10 +77,31 @@ describe("docker build helper", () => { expect(runner).toContain("plugin install record changed unexpectedly"); expect(runner).toContain("index.installRecords ?? index.records ?? config.plugins?.installs"); - expect(runner).not.toContain("Config changed unexpectedly"); + expect(runner).toContain("Config changed unexpectedly for modern package"); expect(runner).not.toContain("before_hash"); }); + it("caps package acceptance legacy compatibility at 2026.4.25", () => { + const scripts = [ + readFileSync(DOCTOR_SWITCH_DOCKER_E2E_PATH, "utf8"), + readFileSync(UPDATE_CHANNEL_SWITCH_DOCKER_E2E_PATH, "utf8"), + readFileSync(PLUGINS_DOCKER_E2E_PATH, "utf8"), + readFileSync(PLUGIN_UPDATE_DOCKER_E2E_PATH, "utf8"), + ]; + + for (const script of scripts) { + expect(script).toContain("2026, 4, 25"); + } + expect(scripts.join("\n")).toContain("OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT"); + expect(scripts.join("\n")).toContain( + "Package $package_version must support gateway install --wrapper.", + ); + expect(scripts.join("\n")).toContain("expected persisted update.channel dev"); + expect(scripts.join("\n")).toContain( + "expected modern installRecords in installed plugin index", + ); + }); + it("passes installer tag env to bash, not curl", () => { const runner = readFileSync(INSTALL_E2E_RUNNER_PATH, "utf8");