From f1538c6d6c1a72effb7a85f00f9bca7d2c5344d5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Jul 2026 07:59:06 -0700 Subject: [PATCH] fix(release): validate complete package sets in candidate smoke (#113600) * fix(release): pass only root dependencies to candidate smoke * fix(release): stage plugin candidates for Parallels * fix(release): bind plugin preflight package identity * fix(test): stream upstream npm tarballs * fix(test): retry post-update gateway startup * fix(test): install fresh candidates through registry * fix(test): route fresh candidates through registry * test(release): update Parallels source contracts * fix(test): split host and guest registry URLs --- .../e2e/lib/plugins/npm-registry-server.mjs | 45 ++++ scripts/e2e/parallels/host-server.ts | 1 + scripts/e2e/parallels/npm-update-scripts.ts | 10 + scripts/e2e/parallels/npm-update-smoke.ts | 68 +++++- scripts/e2e/parallels/types.ts | 1 + scripts/release-candidate-checklist.d.mts | 34 +++ scripts/release-candidate-checklist.mjs | 193 ++++++++++++++++-- .../parallels-npm-update-smoke.test.ts | 32 ++- test/scripts/parallels-smoke-model.test.ts | 5 +- test/scripts/plugins-assertions.test.ts | 62 ++++++ .../release-candidate-checklist.test.ts | 165 ++++++++++++++- 11 files changed, 587 insertions(+), 29 deletions(-) diff --git a/scripts/e2e/lib/plugins/npm-registry-server.mjs b/scripts/e2e/lib/plugins/npm-registry-server.mjs index fc43ea3d2b41..cf2a0e3c14d6 100644 --- a/scripts/e2e/lib/plugins/npm-registry-server.mjs +++ b/scripts/e2e/lib/plugins/npm-registry-server.mjs @@ -1,6 +1,7 @@ import { execFileSync } from "node:child_process"; // Fixture npm registry server for plugin E2E scenarios. import crypto from "node:crypto"; +import { once } from "node:events"; import fs from "node:fs"; import http from "node:http"; import path from "node:path"; @@ -30,6 +31,7 @@ const upstreamRegistry = normalizeUpstreamRegistry(process.env.OPENCLAW_NPM_REGI // inside the install deadline and decoded bodies inside a fixed memory budget. const UPSTREAM_REQUEST_TIMEOUT_MS = 120_000; const UPSTREAM_RESPONSE_MAX_BYTES = 64 * 1024 * 1024; +const UPSTREAM_TARBALL_MAX_BYTES = 512 * 1024 * 1024; if (!portFile || packageArgs.length === 0 || packageArgs.length % 3 !== 0) { console.error( @@ -148,6 +150,11 @@ async function proxyUpstream(rawRequestUrl, response) { redirect: "manual", signal: AbortSignal.timeout(UPSTREAM_REQUEST_TIMEOUT_MS), }); + const requestUrl = new URL(rawRequestUrl || "/", "http://127.0.0.1"); + if (requestUrl.pathname.includes("/-/")) { + await streamUpstreamTarball(upstreamResponse, response); + return true; + } const body = await readBoundedResponseBytes( upstreamResponse, "npm registry upstream", @@ -165,12 +172,50 @@ async function proxyUpstream(rawRequestUrl, response) { response.writeHead(upstreamResponse.status, headers); response.end(body); } catch (error) { + if (response.headersSent) { + response.destroy(error instanceof Error ? error : new Error(String(error))); + return true; + } response.writeHead(502, { "content-type": "text/plain" }); response.end(`upstream registry request failed: ${String(error)}`); } return true; } +async function streamUpstreamTarball(upstreamResponse, response) { + const declaredLength = Number(upstreamResponse.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > UPSTREAM_TARBALL_MAX_BYTES) { + throw new Error(`npm registry upstream tarball exceeded ${UPSTREAM_TARBALL_MAX_BYTES} bytes`); + } + const headers = {}; + for (const name of ["content-type", "location"]) { + const value = upstreamResponse.headers.get(name); + if (value) { + headers[name] = value; + } + } + response.writeHead(upstreamResponse.status, headers); + if (!upstreamResponse.body) { + response.end(); + return; + } + let streamedBytes = 0; + for await (const chunk of upstreamResponse.body) { + const bytes = Buffer.from(chunk); + streamedBytes += bytes.length; + if (streamedBytes > UPSTREAM_TARBALL_MAX_BYTES) { + response.destroy( + new Error(`npm registry upstream tarball exceeded ${UPSTREAM_TARBALL_MAX_BYTES} bytes`), + ); + return; + } + if (!response.write(bytes)) { + await once(response, "drain"); + } + } + response.end(); +} + async function handleRequest(request, response) { const fallbackHost = `127.0.0.1:${server.address().port}`; const requestHost = request.headers.host || fallbackHost; diff --git a/scripts/e2e/parallels/host-server.ts b/scripts/e2e/parallels/host-server.ts index 665f402e3846..235d60bea520 100644 --- a/scripts/e2e/parallels/host-server.ts +++ b/scripts/e2e/parallels/host-server.ts @@ -122,6 +122,7 @@ export async function startNpmRegistryServer(input: { const url = `http://${input.hostIp}:${port}`; say(`Serve prepared npm package set on ${url}`); return { + hostUrl: `http://127.0.0.1:${port}`, url, stop: async () => { try { diff --git a/scripts/e2e/parallels/npm-update-scripts.ts b/scripts/e2e/parallels/npm-update-scripts.ts index d36fd142fd81..04ef9f68ea1e 100644 --- a/scripts/e2e/parallels/npm-update-scripts.ts +++ b/scripts/e2e/parallels/npm-update-scripts.ts @@ -255,10 +255,15 @@ start_openclaw_gateway() { } wait_for_gateway() { deadline=$((SECONDS + 240)) + attempt=0 while [ "$SECONDS" -lt "$deadline" ]; do if "$OPENCLAW_BIN" gateway status --deep --require-rpc --timeout 15000; then return fi + attempt=$((attempt + 1)) + if [ "$attempt" -eq 4 ]; then + start_openclaw_gateway + fi sleep 2 done print_log_tail /tmp/openclaw-parallels-macos-gateway.log >&2 @@ -395,10 +400,15 @@ start_openclaw_gateway() { } wait_for_gateway() { deadline=$((SECONDS + 240)) + attempt=0 while [ "$SECONDS" -lt "$deadline" ]; do if openclaw gateway status --deep --require-rpc --timeout 15000; then return fi + attempt=$((attempt + 1)) + if [ "$attempt" -eq 4 ]; then + start_openclaw_gateway + fi sleep 2 done print_log_tail /tmp/openclaw-parallels-linux-gateway.log >&2 diff --git a/scripts/e2e/parallels/npm-update-smoke.ts b/scripts/e2e/parallels/npm-update-smoke.ts index 40f06242f075..ca9f62d4b86e 100755 --- a/scripts/e2e/parallels/npm-update-smoke.ts +++ b/scripts/e2e/parallels/npm-update-smoke.ts @@ -58,6 +58,7 @@ const LOGGED_POST_FORCE_KILL_WAIT_MS = 1_000; interface NpmUpdateOptions { betaValidation?: string; dependencyTarballs: string[]; + registryPackageTarballs: string[]; freshTargetSpec?: string; hostIp?: string; macosVm?: string; @@ -145,7 +146,7 @@ function resolveSecondsTimerMs(timeoutSeconds: number): number { return finiteSecondsToTimerSafeMilliseconds(timeoutSeconds) ?? 1; } -const updateTimeoutSeconds = readPositiveIntEnv("OPENCLAW_PARALLELS_NPM_UPDATE_TIMEOUT_S", 1200); +const updateTimeoutSeconds = readPositiveIntEnv("OPENCLAW_PARALLELS_NPM_UPDATE_TIMEOUT_S", 2700); const updateCleanupBackstopMs = 60_000; const updateTimeoutMs = resolveSecondsTimerMs(updateTimeoutSeconds); const updateWithCleanupTimeoutMs = @@ -380,6 +381,8 @@ Options: Default: host-served tgz packed from current checkout. --target-tarball Host-serve this prepared tgz for update and fresh install. --dependency-tarball Companion package tgz required by the target. Repeatable. + --registry-package-tarball + Additional package tgz served by the candidate registry. Repeatable. --fresh-target Also run fresh install smoke for this package after update lanes. --beta-validation [target] Resolve a beta tag/alias/version, then run latest->target update plus fresh target install. Default target when flag is bare: beta. @@ -403,6 +406,7 @@ export function parseArgs(argv: string[]): NpmUpdateOptions { apiKeyEnv: undefined, betaValidation: undefined, dependencyTarballs: [], + registryPackageTarballs: [], freshTargetSpec: undefined, json: false, macosVm: undefined, @@ -434,6 +438,10 @@ export function parseArgs(argv: string[]): NpmUpdateOptions { options.dependencyTarballs.push(ensureValue(args, i, arg)); i++; break; + case "--registry-package-tarball": + options.registryPackageTarballs.push(ensureValue(args, i, arg)); + i++; + break; case "--fresh-target": options.freshTargetSpec = ensureValue(args, i, arg); i++; @@ -496,6 +504,9 @@ export function parseArgs(argv: string[]): NpmUpdateOptions { if (options.dependencyTarballs.length > 0 && !options.targetTarball) { throw new Error("--dependency-tarball requires --target-tarball"); } + if (options.registryPackageTarballs.length > 0 && !options.targetTarball) { + throw new Error("--registry-package-tarball requires --target-tarball"); + } return options; } @@ -588,7 +599,9 @@ export class NpmUpdateSmoke { private targetTarballPath = ""; private targetTarballBuildCommit = ""; private targetDependencyPackages: NpmRegistryPackage[] = []; + private targetRegistryPackages: NpmRegistryPackage[] = []; private targetTarballVersion = ""; + private targetRegistryHostUrl = ""; private targetRegistryUrl = ""; private macosVm = macosVmDefault; private linuxVm = linuxVmDefault; @@ -803,6 +816,15 @@ export class NpmUpdateSmoke { "--json", ...extraArgs, ]; + const commandEnv = { + ...env, + ...(phase === "fresh-target" && this.targetRegistryUrl + ? { + NPM_CONFIG_REGISTRY: this.targetRegistryHostUrl, + npm_config_registry: this.targetRegistryHostUrl, + } + : {}), + }; const startedAt = Date.now(); const job: Job = { done: false, @@ -817,14 +839,14 @@ export class NpmUpdateSmoke { attempt === 1 ? () => this.spawnFresh(label, platform, extraArgs, env, packageSpec, phase, attempt + 1) : undefined, - rerunCommand: this.formatRerun("bash", args, env), + rerunCommand: this.formatRerun("bash", args, commandEnv), startedAt, }; job.promise = this.spawnLogged( "bash", args, logPath, - env, + commandEnv, (text) => this.noteJobOutput(job, text), { timeoutLabel: `${label} ${phase}`, @@ -847,7 +869,7 @@ export class NpmUpdateSmoke { path: hostedTarballPath, version: this.targetTarballVersion, }; - if (this.targetDependencyPackages.length > 0) { + if (this.targetDependencyPackages.length > 0 || this.targetRegistryPackages.length > 0) { // Prepared sibling packages publish before core, so pre-publish VM installs need // a local registry that serves the exact package set without touching public npm. this.registryServer = await startNpmRegistryServer({ @@ -859,14 +881,16 @@ export class NpmUpdateSmoke { tarballPath: hostedTarballPath, }, ...this.targetDependencyPackages, + ...this.targetRegistryPackages, ], }); + this.targetRegistryHostUrl = this.registryServer.hostUrl; this.targetRegistryUrl = this.registryServer.url; this.updateTargetTarball = `${this.registryServer.url}/openclaw/-/${path.basename( hostedTarballPath, )}`; this.updateTargetEffective = this.targetTarballVersion; - this.freshTargetSpec = this.updateTargetTarball; + this.freshTargetSpec = `openclaw@${this.targetTarballVersion}`; this.updateExpectedNeedle = this.targetTarballVersion; this.updateTargetPackageVersion = this.targetTarballVersion; this.updateTargetBuildCommit = this.artifact.buildCommitShort ?? ""; @@ -1491,9 +1515,37 @@ export class NpmUpdateSmoke { return { name, version, tarballPath }; }), ); - const dependencyNames = new Set(this.targetDependencyPackages.map((pkg) => pkg.name)); - if (dependencyNames.size !== this.targetDependencyPackages.length) { - throw new Error("dependency tarballs must have unique package names"); + this.targetRegistryPackages = await Promise.all( + this.options.registryPackageTarballs.map(async (registryPackageTarball) => { + const tarballPath = path.resolve(registryPackageTarball); + if (!existsSync(tarballPath)) { + throw new Error(`registry package tarball does not exist: ${tarballPath}`); + } + const registryPackage = await extractPackageJsonFromTgz<{ + name?: string; + version?: string; + }>(tarballPath, "package/package.json"); + const name = registryPackage.name ?? ""; + const version = registryPackage.version ?? ""; + if (!name || !version || name === "openclaw") { + throw new Error(`registry package tarball has invalid metadata: ${tarballPath}`); + } + if (version !== this.targetTarballVersion) { + throw new Error( + `registry package ${name}@${version} does not match candidate ${this.targetTarballVersion}`, + ); + } + return { name, version, tarballPath }; + }), + ); + const registryPackageNames = new Set( + [...this.targetDependencyPackages, ...this.targetRegistryPackages].map((pkg) => pkg.name), + ); + if ( + registryPackageNames.size !== + this.targetDependencyPackages.length + this.targetRegistryPackages.length + ) { + throw new Error("candidate registry tarballs must have unique package names"); } if (!this.targetTarballVersion || !this.targetTarballBuildCommit) { throw new Error( diff --git a/scripts/e2e/parallels/types.ts b/scripts/e2e/parallels/types.ts index f4b357356d1a..f4bf33e26466 100644 --- a/scripts/e2e/parallels/types.ts +++ b/scripts/e2e/parallels/types.ts @@ -53,6 +53,7 @@ export interface NpmRegistryPackage { } export interface NpmRegistryServer { + hostUrl: string; url: string; stop(): Promise; } diff --git a/scripts/release-candidate-checklist.d.mts b/scripts/release-candidate-checklist.d.mts index 7afcc4fe62a3..c16eba58ea35 100644 --- a/scripts/release-candidate-checklist.d.mts +++ b/scripts/release-candidate-checklist.d.mts @@ -10,6 +10,16 @@ export function parseArgs(argv: unknown): { npmDistTag: string; pluginPublishScope: string; plugins: string; + parallelsRegistryPackageArtifactDirs: string[]; + parallelsRegistryPackageArtifacts: Array<{ + artifactDir: string; + artifactName: string; + manifestPath: string; + packageName: string; + packageVersion: string; + tarballPath: string; + tarballSha256: string; + }>; skipDispatch: boolean; skipLocalGeneratedCheck: boolean; skipParallels: boolean; @@ -48,6 +58,7 @@ export function buildReleaseCandidateState( npmDistTag: unknown; pluginPublishScope: unknown; plugins: unknown; + parallelsRegistryPackageArtifacts: unknown; windowsNodeTag: unknown; skipParallels: unknown; skipTelegram: unknown; @@ -56,6 +67,18 @@ export function buildReleaseCandidateState( npmPreflightRunId: unknown; }; export function reconcileReleaseCandidateState(saved: unknown, expected: unknown): unknown; +export function validateParallelsRegistryPackageArtifact( + artifactDir: string, + params: { targetSha: string; targetVersion: string }, +): { + artifactDir: string; + artifactName: string; + manifestPath: string; + packageName: string; + packageVersion: string; + tarballPath: string; + tarballSha256: string; +}; export function buildTelegramArtifactInputs(params: { artifact: { digest?: string; @@ -194,6 +217,15 @@ export function preflightCorePackageTarballs(manifest: { tarballName: string; tarballSha256: string; }>; +export function preflightDependencyTarballs(manifest: { + corePackageTarballs?: unknown; + dependencyTarballs?: unknown; +}): Array<{ + packageName: string; + packageVersion: string; + tarballName: string; + tarballSha256: string; +}>; export function validateFullManifest(manifest: unknown, params: unknown): void; export function validateTrustedToolingPin({ toolingSha, @@ -223,11 +255,13 @@ export function candidateParallelsArgs( tarballPath: unknown, dependencyTarballPaths?: unknown[], toolingRoot?: string, + registryPackageTarballPaths?: unknown[], ): unknown[]; export function candidateParallelsShellCommand( tarballPath: unknown, timeoutBin: unknown, dependencyTarballPaths?: unknown[], + registryPackageTarballPaths?: unknown[], ): string; declare function gitIsAncestor(ancestor: unknown, target: unknown): boolean; declare function loadCandidateShippedBaseline(ref: unknown): { diff --git a/scripts/release-candidate-checklist.mjs b/scripts/release-candidate-checklist.mjs index b88f8091ad0d..4c359281b89c 100644 --- a/scripts/release-candidate-checklist.mjs +++ b/scripts/release-candidate-checklist.mjs @@ -6,6 +6,7 @@ import { existsSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, realpathSync, renameSync, @@ -13,7 +14,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { basename, dirname, join } from "node:path"; +import { basename, dirname, join, resolve as resolvePath } from "node:path"; import { fileURLToPath } from "node:url"; import { isDeepStrictEqual } from "node:util"; import { stripLeadingPackageManagerSeparator } from "./lib/arg-utils.mjs"; @@ -53,7 +54,8 @@ const WINDOWS_NODE_REQUIRED_ASSETS = [ "OpenClawCompanion-Setup-arm64.exe", ]; const SHA256_DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u; -const RELEASE_CANDIDATE_STATE_VERSION = 1; +const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/u; +const RELEASE_CANDIDATE_STATE_VERSION = 2; const RELEASE_CANDIDATE_STATE_FILE = "release-candidate-state.json"; const TRUSTED_TOOLING_SHA_ENV = "OPENCLAW_RELEASE_CANDIDATE_TRUSTED_TOOLING_SHA"; const RELEASE_CANDIDATE_STATE_KEYS = [ @@ -68,6 +70,7 @@ const RELEASE_CANDIDATE_STATE_KEYS = [ "npmDistTag", "pluginPublishScope", "plugins", + "parallelsRegistryPackageArtifacts", "windowsNodeTag", "skipParallels", "skipTelegram", @@ -91,6 +94,8 @@ Options: --skip-dispatch Require both run ids; do not dispatch workflows. --skip-local-generated-check Do not run local generated release baseline checks before dispatch. --skip-parallels Do not run local Parallels fresh/update candidate smoke. + --parallels-registry-package-artifact + Add a verified plugin npm preflight artifact directory. Repeatable. --skip-telegram Do not run NPM Telegram E2E against the prepared tarball. --telegram-provider-mode mock-openai|live-frontier. Default: ${DEFAULT_TELEGRAM_PROVIDER_MODE} --provider Full validation provider. Default: ${DEFAULT_PROVIDER} @@ -132,6 +137,8 @@ export function parseArgs(argv) { npmDistTag: DEFAULT_NPM_DIST_TAG, pluginPublishScope: DEFAULT_PLUGIN_SCOPE, plugins: "", + parallelsRegistryPackageArtifactDirs: [], + parallelsRegistryPackageArtifacts: [], skipDispatch: false, skipLocalGeneratedCheck: false, skipParallels: false, @@ -185,6 +192,9 @@ export function parseArgs(argv) { case "--skip-parallels": setOnce(arg, "skipParallels", true); break; + case "--parallels-registry-package-artifact": + options.parallelsRegistryPackageArtifactDirs.push(requireValue(args, ++index, arg)); + break; case "--skip-telegram": setOnce(arg, "skipTelegram", true); break; @@ -297,6 +307,67 @@ function readJson(path, label) { } } +export function validateParallelsRegistryPackageArtifact(artifactDir, params) { + const resolvedDir = resolvePath(artifactDir); + const manifestPath = join(resolvedDir, "plugin-publication-manifest.json"); + const manifest = readJson(manifestPath, "plugin npm preflight manifest"); + const artifactName = manifest.artifact?.name; + const tarballName = manifest.artifact?.tarball; + const tarballSha256 = manifest.artifact?.sha256; + const packageName = manifest.package?.name; + const packageVersion = manifest.package?.version; + if ( + manifest.schema !== "openclaw.plugin-publication-artifact/v1" || + manifest.schemaVersion !== 1 || + manifest.targetSha !== params.targetSha || + !artifactName || + !packageName || + packageVersion !== params.targetVersion || + !tarballName || + tarballName !== basename(tarballName) || + !SHA256_HEX_PATTERN.test(tarballSha256) + ) { + throw new Error(`plugin npm preflight artifact identity is invalid: ${resolvedDir}`); + } + const tarballPath = join(resolvedDir, tarballName); + const compareFileNames = (left, right) => left.localeCompare(right); + const files = readdirSync(resolvedDir).toSorted(compareFileNames); + const expectedFiles = [basename(manifestPath), tarballName].toSorted(compareFileNames); + if (!isDeepStrictEqual(files, expectedFiles) || !existsSync(tarballPath)) { + throw new Error(`plugin npm preflight artifact inventory is invalid: ${resolvedDir}`); + } + const actualSha256 = sha256(tarballPath); + if (actualSha256 !== tarballSha256) { + throw new Error( + `plugin npm preflight tarball digest mismatch for ${packageName}: expected ${tarballSha256}, got ${actualSha256}`, + ); + } + let packedPackage; + try { + packedPackage = JSON.parse( + run("tar", ["-xOf", tarballPath, "package/package.json"], { capture: true }), + ); + } catch (error) { + throw new Error(`plugin npm preflight tarball package metadata is invalid: ${tarballPath}`, { + cause: error, + }); + } + if (packedPackage.name !== packageName || packedPackage.version !== packageVersion) { + throw new Error( + `plugin npm preflight tarball identity mismatch: manifest=${packageName}@${packageVersion} packed=${packedPackage.name ?? ""}@${packedPackage.version ?? ""}`, + ); + } + return { + artifactDir: resolvedDir, + artifactName, + manifestPath, + packageName, + packageVersion, + tarballPath, + tarballSha256, + }; +} + export function buildReleaseCandidateState(options, { targetSha, toolingSha }) { return { version: RELEASE_CANDIDATE_STATE_VERSION, @@ -312,6 +383,7 @@ export function buildReleaseCandidateState(options, { targetSha, toolingSha }) { npmDistTag: options.npmDistTag, pluginPublishScope: options.pluginPublishScope, plugins: options.plugins, + parallelsRegistryPackageArtifacts: options.parallelsRegistryPackageArtifacts, windowsNodeTag: options.windowsNodeTag, skipParallels: options.skipParallels, skipTelegram: options.skipTelegram, @@ -1200,7 +1272,9 @@ export function validatePreflightManifest(manifest, params) { if (!manifest.tarballName || !manifest.tarballSha256) { throw new Error("npm preflight manifest missing tarball metadata"); } - for (const dependency of preflightCorePackageTarballs(manifest)) { + const corePackageTarballs = preflightCorePackageTarballs(manifest); + const dependencyTarballs = preflightDependencyTarballs(manifest); + for (const dependency of [...corePackageTarballs, ...dependencyTarballs]) { if ( !dependency?.packageName || !dependency.packageVersion || @@ -1211,6 +1285,23 @@ export function validatePreflightManifest(manifest, params) { throw new Error("npm preflight manifest contains invalid dependency tarball metadata"); } } + const corePackageDescriptors = new Set(corePackageTarballs.map(preflightTarballDescriptorKey)); + for (const dependency of dependencyTarballs) { + if (!corePackageDescriptors.has(preflightTarballDescriptorKey(dependency))) { + throw new Error( + `npm preflight dependency tarball metadata does not match the core package manifest: ${dependency.packageName}`, + ); + } + } +} + +function preflightTarballDescriptorKey(tarball) { + return JSON.stringify([ + tarball.packageName, + tarball.packageVersion, + tarball.tarballName, + tarball.tarballSha256, + ]); } export function preflightCorePackageTarballs(manifest) { @@ -1224,6 +1315,17 @@ export function preflightCorePackageTarballs(manifest) { return tarballs; } +export function preflightDependencyTarballs(manifest) { + const hasDependencyTarballs = Object.hasOwn(manifest, "dependencyTarballs"); + const tarballs = hasDependencyTarballs + ? manifest.dependencyTarballs + : manifest.corePackageTarballs; + if (!Array.isArray(tarballs)) { + throw new Error("npm preflight manifest missing dependency tarball metadata"); + } + return tarballs; +} + export function validateFullManifest(manifest, params) { if (manifest.workflowName !== "Full Release Validation") { throw new Error(`full validation workflow mismatch: ${manifest.workflowName}`); @@ -1258,6 +1360,7 @@ export function candidateParallelsArgs( tarballPath, dependencyTarballPaths = [], toolingRoot = TOOLING_ROOT, + registryPackageTarballPaths = [], ) { return [ "exec", @@ -1266,6 +1369,10 @@ export function candidateParallelsArgs( "--target-tarball", tarballPath, ...dependencyTarballPaths.flatMap((dependency) => ["--dependency-tarball", dependency]), + ...registryPackageTarballPaths.flatMap((registryPackage) => [ + "--registry-package-tarball", + registryPackage, + ]), "--json", ]; } @@ -1274,6 +1381,7 @@ export function candidateParallelsShellCommand( tarballPath, timeoutBin, dependencyTarballPaths = [], + registryPackageTarballPaths = [], ) { // Login shells can replace the candidate's supported Node with ambient host Node. // Keep the invoking Node first so pnpm and npm use the validated runtime. @@ -1286,11 +1394,21 @@ export function candidateParallelsShellCommand( "--foreground", "150m", "pnpm", - ...candidateParallelsArgs(tarballPath, dependencyTarballPaths).map(shellQuote), + ...candidateParallelsArgs( + tarballPath, + dependencyTarballPaths, + TOOLING_ROOT, + registryPackageTarballPaths, + ).map(shellQuote), ].join(" "); } -async function runParallelsIfNeeded(options, tarballPath, dependencyTarballPaths) { +async function runParallelsIfNeeded( + options, + tarballPath, + dependencyTarballPaths, + registryPackageTarballPaths, +) { if (options.skipParallels) { return { status: "skipped", reason: "operator skipped --skip-parallels" }; } @@ -1302,7 +1420,12 @@ async function runParallelsIfNeeded(options, tarballPath, dependencyTarballPaths const timeoutBin = run("bash", ["-lc", "command -v gtimeout || command -v timeout"], { capture: true, }).trim(); - const command = candidateParallelsShellCommand(tarballPath, timeoutBin, dependencyTarballPaths); + const command = candidateParallelsShellCommand( + tarballPath, + timeoutBin, + dependencyTarballPaths, + registryPackageTarballPaths, + ); run("bash", ["-lc", command], { env: { OPENCLAW_PARALLELS_ARTIFACT_ROOT: join(process.cwd(), ".artifacts", "parallels"), @@ -1403,6 +1526,19 @@ async function main() { toolingTrackedStatus: gitTrackedStatus(TOOLING_ROOT), workflowRef: options.workflowRef, }); + options.parallelsRegistryPackageArtifacts = options.parallelsRegistryPackageArtifactDirs.map( + (artifactDir) => + validateParallelsRegistryPackageArtifact(artifactDir, { + targetSha, + targetVersion: options.tag.replace(/^v/u, ""), + }), + ); + const registryPackageNames = new Set( + options.parallelsRegistryPackageArtifacts.map((artifact) => artifact.packageName), + ); + if (registryPackageNames.size !== options.parallelsRegistryPackageArtifacts.length) { + throw new Error("Parallels registry package artifacts must have unique package names"); + } const statePath = join(options.outputDir, RELEASE_CANDIDATE_STATE_FILE); const expectedState = buildReleaseCandidateState(options, { targetSha, toolingSha }); let candidateState = reconcileReleaseCandidateState( @@ -1542,21 +1678,47 @@ async function main() { `prepared tarball digest mismatch: expected ${npmManifest.tarballSha256}, got ${actualTarballSha}`, ); } - const dependencyTarballPaths = preflightCorePackageTarballs(npmManifest).map((dependency) => { - const dependencyPath = join(npmDir, dependency.tarballName); - if (!existsSync(dependencyPath)) { - throw new Error(`prepared dependency tarball missing: ${dependencyPath}`); - } - const actualDependencySha = sha256(dependencyPath); - if (actualDependencySha !== dependency.tarballSha256) { + const corePackageTarballPaths = new Map( + preflightCorePackageTarballs(npmManifest).map((dependency) => { + const dependencyPath = join(npmDir, dependency.tarballName); + if (!existsSync(dependencyPath)) { + throw new Error(`prepared dependency tarball missing: ${dependencyPath}`); + } + const actualDependencySha = sha256(dependencyPath); + if (actualDependencySha !== dependency.tarballSha256) { + throw new Error( + `prepared dependency tarball digest mismatch for ${dependency.packageName}: expected ${dependency.tarballSha256}, got ${actualDependencySha}`, + ); + } + return [preflightTarballDescriptorKey(dependency), dependencyPath]; + }), + ); + const dependencyTarballPaths = preflightDependencyTarballs(npmManifest).map((dependency) => { + const dependencyPath = corePackageTarballPaths.get(preflightTarballDescriptorKey(dependency)); + if (!dependencyPath) { throw new Error( - `prepared dependency tarball digest mismatch for ${dependency.packageName}: expected ${dependency.tarballSha256}, got ${actualDependencySha}`, + `prepared dependency tarball is missing from the core package manifest: ${dependency.tarballName}`, ); } return dependencyPath; }); - const parallels = await runParallelsIfNeeded(options, tarballPath, dependencyTarballPaths); + const revalidatedRegistryArtifacts = options.parallelsRegistryPackageArtifactDirs.map( + (artifactDir) => + validateParallelsRegistryPackageArtifact(artifactDir, { + targetSha, + targetVersion: options.tag.replace(/^v/u, ""), + }), + ); + if (!isDeepStrictEqual(revalidatedRegistryArtifacts, options.parallelsRegistryPackageArtifacts)) { + throw new Error("Parallels registry package artifacts changed during candidate validation"); + } + const parallels = await runParallelsIfNeeded( + options, + tarballPath, + dependencyTarballPaths, + revalidatedRegistryArtifacts.map((artifact) => artifact.tarballPath), + ); const npmTelegram = await runTelegramIfNeeded( options, npmArtifact, @@ -1605,6 +1767,7 @@ async function main() { path: tarballPath, }, parallels, + parallelsRegistryPackageArtifacts: revalidatedRegistryArtifacts, npmTelegram, pluginNpmPlan, pluginClawHubPlan, diff --git a/test/scripts/parallels-npm-update-smoke.test.ts b/test/scripts/parallels-npm-update-smoke.test.ts index 44355a04e25a..116af0d348ee 100644 --- a/test/scripts/parallels-npm-update-smoke.test.ts +++ b/test/scripts/parallels-npm-update-smoke.test.ts @@ -116,9 +116,12 @@ describe("parallels npm update smoke", () => { "/tmp/openclaw-candidate.tgz", "--dependency-tarball", "/tmp/openclaw-ai-candidate.tgz", + "--registry-package-tarball", + "/tmp/openclaw-codex-candidate.tgz", ]), ).toMatchObject({ dependencyTarballs: ["/tmp/openclaw-ai-candidate.tgz"], + registryPackageTarballs: ["/tmp/openclaw-codex-candidate.tgz"], targetTarball: "/tmp/openclaw-candidate.tgz", updateTarget: "", freshTargetSpec: undefined, @@ -129,6 +132,9 @@ describe("parallels npm update smoke", () => { expect(() => parseArgs(["--dependency-tarball", "/tmp/openclaw-ai-candidate.tgz"])).toThrow( "--dependency-tarball requires --target-tarball", ); + expect(() => + parseArgs(["--registry-package-tarball", "/tmp/openclaw-codex-candidate.tgz"]), + ).toThrow("--registry-package-tarball requires --target-tarball"); }); it("stops the host artifact server when the wrapper fails mid-run", async () => { @@ -158,6 +164,7 @@ describe("parallels npm update smoke", () => { const smoke = new FailingNpmUpdateSmoke({ ...TEST_AUTH, dependencyTarballs: [], + registryPackageTarballs: [], json: false, packageSpec: "openclaw@latest", platforms: new Set(["linux"]), @@ -208,6 +215,7 @@ exit 1 const smoke = new NpmUpdateSmoke({ ...TEST_AUTH, dependencyTarballs: [], + registryPackageTarballs: [], json: false, packageSpec: "openclaw@latest", platforms: new Set(["linux"]), @@ -253,12 +261,17 @@ exit 1 expect(script).toContain("--target-tarball "); expect(script).toContain("--dependency-tarball "); + expect(script).toContain("--registry-package-tarball "); expect(script).toContain('label: "prepared candidate tgz"'); expect(script).toContain("await copyFile(this.targetTarballPath, hostedTarballPath)"); expect(script).toContain("startNpmRegistryServer"); + expect(script).toContain("...this.targetRegistryPackages"); expect(script).toContain("this.updateTargetEffective = this.targetTarballVersion"); - expect(script).toContain("this.freshTargetSpec = this.updateTargetTarball"); + expect(script).toContain("this.freshTargetSpec = `openclaw@${this.targetTarballVersion}`"); + expect(script).toContain("NPM_CONFIG_REGISTRY: this.targetRegistryHostUrl"); + expect(script).toContain("npm_config_registry: this.targetRegistryHostUrl"); expect(script).toContain("this.updateExpectedNeedle = this.targetTarballVersion"); + expect(script).toContain('readPositiveIntEnv("OPENCLAW_PARALLELS_NPM_UPDATE_TIMEOUT_S", 2700)'); }); it("routes update installs through the prepared package registry", () => { @@ -275,6 +288,19 @@ exit 1 expect(windowsUpdateScript(input)).toContain(`NPM_CONFIG_REGISTRY = '${registry}'`); }); + it("relaunches POSIX gateways after a transient post-update startup failure", () => { + const input = { + auth: TEST_AUTH, + expectedNeedle: "2026.7.2-beta.5", + updateTarget: "2026.7.2-beta.5", + }; + + for (const script of [macosUpdateScript(input), linuxUpdateScript(input)]) { + expect(script).toContain("attempt=$((attempt + 1))"); + expect(script).toContain('if [ "$attempt" -eq 4 ]; then\n start_openclaw_gateway'); + } + }); + it("does not recreate retired workspace setup state in release smoke scripts", () => { const input = { auth: TEST_AUTH, @@ -565,6 +591,7 @@ exit 1 new NpmUpdateSmoke({ ...TEST_AUTH, dependencyTarballs: [], + registryPackageTarballs: [], json: false, packageSpec: "openclaw@latest", platforms: new Set(["linux"]), @@ -606,6 +633,7 @@ exit 1 new NpmUpdateSmoke({ ...TEST_AUTH, dependencyTarballs: [], + registryPackageTarballs: [], json: false, packageSpec: "openclaw@latest", platforms: new Set(["linux"]), @@ -839,6 +867,7 @@ exit 7 const smoke = new NpmUpdateSmoke({ ...TEST_AUTH, dependencyTarballs: [], + registryPackageTarballs: [], json: false, packageSpec: "openclaw@latest", platforms: new Set(["macos"]), @@ -881,6 +910,7 @@ exit 7 const smoke = new NpmUpdateSmoke({ ...TEST_AUTH, dependencyTarballs: [], + registryPackageTarballs: [], json: false, packageSpec: "openclaw@latest", platforms: new Set(["macos"]), diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index 3c7161e9f1a8..98c9dacf2fce 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -507,6 +507,7 @@ fetch_host_metadata "https://example.test/metadata"`, expect(parallelsVm).toContain("export function waitForVmStatus"); expect(hostServer).toContain("export async function startHostServer"); expect(hostServer).toContain("export async function startNpmRegistryServer"); + expect(hostServer).toContain("hostUrl: `http://127.0.0.1:${port}`"); expect(hostServer).toContain('OPENCLAW_NPM_REGISTRY_UPSTREAM: "https://registry.npmjs.org"'); expect(hostServer).toContain("http.server"); expect(snapshots).toContain("export function resolveSnapshot"); @@ -1489,7 +1490,7 @@ exit 0 const script = readFileSync(TS_PATHS.npmUpdate, "utf8"); expect(script).toContain("scripts/e2e/parallels-${platform}-smoke.sh"); - expect(script).toContain('this.formatRerun("bash", args, env)'); + expect(script).toContain('this.formatRerun("bash", args, commandEnv)'); expect(script).toContain('"--model"'); expect(script).toContain("auth.modelId"); expect(script).toContain("authForPlatform"); @@ -2253,7 +2254,7 @@ setInterval(() => {}, 1000); 'readPositiveIntEnv("OPENCLAW_PARALLELS_PACKAGE_LOCK_TIMEOUT_MS", 30 * 60_000)', ); expect(readFileSync(TS_PATHS.npmUpdate, "utf8")).toContain( - 'readPositiveIntEnv("OPENCLAW_PARALLELS_NPM_UPDATE_TIMEOUT_S", 1200)', + 'readPositiveIntEnv("OPENCLAW_PARALLELS_NPM_UPDATE_TIMEOUT_S", 2700)', ); }); diff --git a/test/scripts/plugins-assertions.test.ts b/test/scripts/plugins-assertions.test.ts index 5efea4ce28e1..c3bcf5d6cf6c 100644 --- a/test/scripts/plugins-assertions.test.ts +++ b/test/scripts/plugins-assertions.test.ts @@ -868,6 +868,68 @@ ${command} } }); + it("streams proxied npm tarballs without buffering a content length", async () => { + const root = autoCleanupTempDirs.make("openclaw-plugin-npm-fixture-tarball-proxy-"); + const portFile = path.join(root, "port"); + const tarballPath = path.join(root, "demo-plugin.tgz"); + const upstreamBody = "x".repeat(1024 * 1024); + writeFileSync(tarballPath, "fixture package archive", "utf8"); + + const upstream = createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/octet-stream" }); + response.write(upstreamBody.slice(0, upstreamBody.length / 2)); + response.end(upstreamBody.slice(upstreamBody.length / 2)); + }); + await new Promise((resolve) => { + upstream.listen(0, "127.0.0.1", resolve); + }); + const upstreamAddress = upstream.address(); + if (!upstreamAddress || typeof upstreamAddress === "string") { + throw new Error("expected upstream registry address"); + } + + const child = spawn( + process.execPath, + [ + "scripts/e2e/lib/plugins/npm-registry-server.mjs", + portFile, + "@openclaw/demo-plugin-npm", + "1.0.0", + tarballPath, + ], + { + cwd: process.cwd(), + env: { + ...process.env, + OPENCLAW_NPM_REGISTRY_UPSTREAM: `http://127.0.0.1:${upstreamAddress.port}`, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + try { + const port = await waitForPortFile(portFile); + const response = await requestFixtureRegistry( + port, + "/@openai/codex/-/codex-0.145.0-linux-arm64.tgz", + ); + + expect(response.statusCode).toBe(200); + expect(response.contentLength).toBeUndefined(); + expect(response.body).toBe(upstreamBody); + } finally { + if (child.exitCode === null) { + child.kill(); + await new Promise((resolve) => { + child.once("close", resolve); + }); + } + await new Promise((resolve) => { + upstream.close(() => resolve()); + }); + } + }); + it("rejects oversized upstream bodies without stopping the fixture registry", async () => { const root = autoCleanupTempDirs.make("openclaw-plugin-npm-fixture-proxy-limit-"); const portFile = path.join(root, "port"); diff --git a/test/scripts/release-candidate-checklist.test.ts b/test/scripts/release-candidate-checklist.test.ts index 0daece596720..e205b5071f19 100644 --- a/test/scripts/release-candidate-checklist.test.ts +++ b/test/scripts/release-candidate-checklist.test.ts @@ -1,7 +1,9 @@ +import { execFileSync } from "node:child_process"; // Release Candidate Checklist tests cover release candidate checklist script behavior. -import { readFileSync } from "node:fs"; -import { dirname } from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { parse } from "yaml"; import { buildReleaseCandidateState, @@ -15,6 +17,7 @@ import { parseArgs, parseRunIdFromDispatchOutput, preflightCorePackageTarballs, + preflightDependencyTarballs, reconcileReleaseCandidateState, releaseBranchForTag, resolveArtifactName, @@ -25,10 +28,14 @@ import { validateCandidateReleaseNotes, validateFullManifest, validateNpmPreflightRunSource, + validateParallelsRegistryPackageArtifact, validatePreflightManifest, validateTrustedToolingPin, validateWindowsSourceRelease, } from "../../scripts/release-candidate-checklist.mjs"; +import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); function jsonResponse(body: unknown, init: ResponseInit = {}): Response { return new Response(JSON.stringify(body), init); @@ -535,6 +542,7 @@ describe("release candidate checklist", () => { ".artifacts/preflight/openclaw.tgz", [".artifacts/preflight/openclaw-ai.tgz"], "/trusted", + [".artifacts/preflight/openclaw-codex.tgz"], ), ).toEqual([ "exec", @@ -544,10 +552,95 @@ describe("release candidate checklist", () => { ".artifacts/preflight/openclaw.tgz", "--dependency-tarball", ".artifacts/preflight/openclaw-ai.tgz", + "--registry-package-tarball", + ".artifacts/preflight/openclaw-codex.tgz", "--json", ]); }); + it("accepts repeatable candidate registry package artifacts", () => { + expect( + parseArgs([ + "--tag", + "v2026.7.1-beta.3", + "--parallels-registry-package-artifact", + "/tmp/codex-artifact", + "--parallels-registry-package-artifact", + "/tmp/matrix-artifact", + ]).parallelsRegistryPackageArtifactDirs, + ).toEqual(["/tmp/codex-artifact", "/tmp/matrix-artifact"]); + }); + + it("binds Parallels registry packages to plugin preflight manifests", () => { + const artifactDir = tempDirs.make("openclaw-plugin-preflight-"); + const tarballName = "openclaw-codex-2026.7.1-beta.3.tgz"; + const tarballPath = join(artifactDir, tarballName); + const sourceDir = join(artifactDir, "source"); + const packageDir = join(sourceDir, "package"); + mkdirSync(packageDir, { recursive: true }); + writeFileSync( + join(packageDir, "package.json"), + `${JSON.stringify({ name: "@openclaw/codex", version: "2026.7.1-beta.3" })}\n`, + ); + execFileSync("tar", ["-czf", tarballPath, "-C", sourceDir, "package"]); + rmSync(sourceDir, { force: true, recursive: true }); + const tarballSha256 = createHash("sha256").update(readFileSync(tarballPath)).digest("hex"); + const manifestPath = join(artifactDir, "plugin-publication-manifest.json"); + const manifest = { + schema: "openclaw.plugin-publication-artifact/v1", + schemaVersion: 1, + targetSha: "candidate-sha", + package: { name: "@openclaw/codex", version: "2026.7.1-beta.3" }, + artifact: { + name: "plugin-npm-package-codex", + tarball: tarballName, + sha256: tarballSha256, + }, + }; + writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); + + expect( + validateParallelsRegistryPackageArtifact(artifactDir, { + targetSha: "candidate-sha", + targetVersion: "2026.7.1-beta.3", + }), + ).toMatchObject({ + artifactName: "plugin-npm-package-codex", + packageName: "@openclaw/codex", + packageVersion: "2026.7.1-beta.3", + tarballPath, + tarballSha256, + }); + mkdirSync(packageDir, { recursive: true }); + writeFileSync( + join(packageDir, "package.json"), + `${JSON.stringify({ name: "@openclaw/matrix", version: "2026.7.1-beta.3" })}\n`, + ); + execFileSync("tar", ["-czf", tarballPath, "-C", sourceDir, "package"]); + rmSync(sourceDir, { force: true, recursive: true }); + const mismatchedSha256 = createHash("sha256").update(readFileSync(tarballPath)).digest("hex"); + writeFileSync( + manifestPath, + `${JSON.stringify({ + ...manifest, + artifact: { ...manifest.artifact, sha256: mismatchedSha256 }, + })}\n`, + ); + expect(() => + validateParallelsRegistryPackageArtifact(artifactDir, { + targetSha: "candidate-sha", + targetVersion: "2026.7.1-beta.3", + }), + ).toThrow("tarball identity mismatch"); + writeFileSync(manifestPath, `${JSON.stringify({ ...manifest, targetSha: "other-sha" })}\n`); + expect(() => + validateParallelsRegistryPackageArtifact(artifactDir, { + targetSha: "candidate-sha", + targetVersion: "2026.7.1-beta.3", + }), + ).toThrow("artifact identity is invalid"); + }); + it("requires exact dependency tarball metadata in npm preflight manifests", () => { const manifest = { releaseTag: "v2026.7.1-beta.3", @@ -621,6 +714,72 @@ describe("release candidate checklist", () => { ).toThrow("missing dependency tarball metadata"); }); + it("passes only root dependency tarballs to Parallels with legacy fallback", () => { + const aiTarball = { + packageName: "@openclaw/ai", + packageVersion: "2026.7.1-beta.3", + tarballName: "openclaw-ai-2026.7.1-beta.3.tgz", + tarballSha256: "ai-sha", + }; + const gatewayProtocolTarball = { + packageName: "@openclaw/gateway-protocol", + packageVersion: "2026.7.1-beta.3", + tarballName: "openclaw-gateway-protocol-2026.7.1-beta.3.tgz", + tarballSha256: "protocol-sha", + }; + const gatewayClientTarball = { + packageName: "@openclaw/gateway-client", + packageVersion: "2026.7.1-beta.3", + tarballName: "openclaw-gateway-client-2026.7.1-beta.3.tgz", + tarballSha256: "client-sha", + }; + const corePackageTarballs = [aiTarball, gatewayProtocolTarball, gatewayClientTarball]; + + expect( + preflightDependencyTarballs({ + corePackageTarballs, + dependencyTarballs: [aiTarball], + }), + ).toEqual([aiTarball]); + expect(preflightDependencyTarballs({ corePackageTarballs })).toEqual(corePackageTarballs); + const manifest = { + releaseTag: "v2026.7.1-beta.3", + releaseSha: "candidate-sha", + npmDistTag: "beta", + tarballName: "openclaw-2026.7.1-beta.3.tgz", + tarballSha256: "root-sha", + corePackageTarballs, + dependencyTarballs: [aiTarball], + }; + const params = { + tag: manifest.releaseTag, + targetSha: manifest.releaseSha, + npmDistTag: manifest.npmDistTag, + }; + expect(() => validatePreflightManifest(manifest, params)).not.toThrow(); + expect(() => + validatePreflightManifest( + { + ...manifest, + dependencyTarballs: [ + { + ...aiTarball, + tarballName: gatewayProtocolTarball.tarballName, + tarballSha256: gatewayProtocolTarball.tarballSha256, + }, + ], + }, + params, + ), + ).toThrow("does not match the core package manifest"); + expect(() => + preflightDependencyTarballs({ + corePackageTarballs, + dependencyTarballs: null, + }), + ).toThrow("missing dependency tarball metadata"); + }); + it("trusts the npm workflow SHA while binding the candidate through its manifest", () => { const workflowSha = "a".repeat(40); const isTrustedWorkflowAncestor = vi.fn(() => true);