fix: validate extended-stable package upgrades (#161450)

* fix(release): validate extended-stable package upgrades

* fix(release): route upgrades by baseline channel

* fix(release): pin extended-stable registry upgrades

* fix(release): bind extended-stable registry candidate
This commit is contained in:
Dallin Romney 2026-09-29 20:49:42 -07:00 • committed by GitHub
parent 1eaef188e4
commit 7a438dc93e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 404 additions and 28 deletions

View file

@ -237,6 +237,32 @@ function findPackageForPath(pathname) {
return packageName === undefined ? undefined : packages.get(packageName);
}
function findPackageTargetForPath(pathname) {
for (const entry of packages.values()) {
const prefixes = [
`/${entry.encodedPackageName}/`,
`/${encodeURIComponent(entry.packageName)}/`,
`/${entry.packageName}/`,
];
const prefix = prefixes.find((candidate) =>
pathname.toLowerCase().startsWith(candidate.toLowerCase()),
);
if (!prefix) {
continue;
}
const encodedTarget = pathname.slice(prefix.length);
if (!encodedTarget || encodedTarget.includes("/")) {
continue;
}
try {
return { entry, target: decodeURIComponent(encodedTarget) };
} catch {
return undefined;
}
}
return undefined;
}
function findTarballForPath(pathname) {
for (const entry of packages.values()) {
const prefixes = [`/${entry.encodedPackageName}/-/`, `/${entry.packageName}/-/`];
@ -367,6 +393,18 @@ async function handleRequest(request, response) {
return;
}
const packageTarget = findPackageTargetForPath(url.pathname);
if (packageTarget) {
const metadata = await metadataWithPublishedVersions(packageTarget.entry, baseUrl);
const version = metadata["dist-tags"]?.[packageTarget.target] ?? packageTarget.target;
const manifest = metadata.versions?.[version];
if (manifest) {
response.writeHead(200, { "content-type": "application/json" });
response.end(`${JSON.stringify(manifest)}\n`);
return;
}
}
const tarballEntry = findTarballForPath(url.pathname);
if (tarballEntry) {
response.writeHead(200, {

View file

@ -3,7 +3,11 @@ import { closeSync, existsSync, mkdtempSync, openSync, readFileSync, rmSync } fr
import { join, resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { validatePrepublishPluginRegistryArtifact } from "../../prepublish-plugin-registry-artifact.mjs";
import {
inspectNpmPackageTarball,
validatePrepublishPluginRegistryArtifact,
} from "../../prepublish-plugin-registry-artifact.mjs";
import { classifyReleaseTrain, parseReleaseVersion } from "../release-version.mjs";
import { hasChildExited, registerActiveChildProcessTree } from "./process.ts";
type CrossOsCompanionPackage = {
@ -11,6 +15,12 @@ type CrossOsCompanionPackage = {
tarballPath: string;
};
type CrossOsRegistryPackage = {
name: string;
version: string;
tarballPath: string;
};
export function resolveCrossOsPackageSet(params: {
artifactDir: string;
candidateVersion: string;
@ -35,21 +45,64 @@ export function resolveCrossOsPackageSet(params: {
}));
return {
companions,
// Baseline and installer selectors still belong to the published registry.
// The candidate root is installed by its explicit tarball path.
packages: manifest.packages
.filter((entry: { name: string }) => entry.name !== "openclaw")
.map((entry: { name: string; version: string; tarball: string }) => ({
packages: manifest.packages.map(
(entry: { name: string; version: string; tarball: string }) => ({
name: entry.name,
version: entry.version,
tarballPath: resolve(artifactDir, entry.tarball),
})),
}),
),
};
}
export function resolveCrossOsRegistryDistTags(
packages: ReturnType<typeof resolveCrossOsPackageSet>["packages"],
): string | undefined {
const rootVersion = packages.find((entry) => entry.name === "openclaw")?.version;
const parsed = rootVersion ? parseReleaseVersion(rootVersion) : null;
if (!parsed || classifyReleaseTrain(parsed) !== "extended-stable") {
return undefined;
}
return `extended-stable=${rootVersion}`;
}
export function bindCrossOsCandidateRootPackage(
packages: CrossOsRegistryPackage[],
candidate: { version: string; tarballPath: string },
): CrossOsRegistryPackage[] {
const inspectedCandidate = inspectNpmPackageTarball(candidate.tarballPath);
if (
inspectedCandidate.packageJson.name !== "openclaw" ||
inspectedCandidate.packageJson.version !== candidate.version
) {
throw new Error("Candidate root tarball identity differs from the selected release candidate.");
}
const artifactRoot = packages.find((entry) => entry.name === "openclaw");
if (artifactRoot) {
const inspectedArtifactRoot = inspectNpmPackageTarball(artifactRoot.tarballPath);
if (
artifactRoot.version !== candidate.version ||
inspectedArtifactRoot.sha256 !== inspectedCandidate.sha256
) {
throw new Error("Candidate root registry bytes differ from the selected package artifact.");
}
}
return [
...packages.filter((entry) => entry.name !== "openclaw"),
{ name: "openclaw", version: candidate.version, tarballPath: candidate.tarballPath },
].toSorted((a, b) => a.name.localeCompare(b.name));
}
export function omitCrossOsCandidateRootPackage(
packages: CrossOsRegistryPackage[],
): CrossOsRegistryPackage[] {
return packages.filter((entry) => entry.name !== "openclaw");
}
export async function startCrossOsPackageRegistry(
packages: ReturnType<typeof resolveCrossOsPackageSet>["packages"],
logsDir: string,
options: { upstreamRegistry?: string } = {},
) {
if (packages.length === 0) {
return undefined;
@ -68,9 +121,10 @@ export async function startCrossOsPackageRegistry(
env: {
...process.env,
OPENCLAW_NPM_REGISTRY_BIND_HOST: "127.0.0.1",
OPENCLAW_NPM_REGISTRY_DIST_TAGS: resolveCrossOsRegistryDistTags(packages),
OPENCLAW_NPM_REGISTRY_PORT: "0",
OPENCLAW_NPM_REGISTRY_MERGE_UPSTREAM: "1",
OPENCLAW_NPM_REGISTRY_UPSTREAM: "https://registry.npmjs.org",
OPENCLAW_NPM_REGISTRY_UPSTREAM: options.upstreamRegistry ?? "https://registry.npmjs.org",
},
stdio: ["ignore", log, log],
detached: process.platform !== "win32",

View file

@ -1,6 +1,10 @@
import type { ChildProcess } from "node:child_process";
import { basename, dirname, resolve, win32 as pathWin32 } from "node:path";
import { compareReleaseVersions } from "../release-version.mjs";
import {
classifyReleaseTrain,
compareReleaseVersions,
parseReleaseVersion,
} from "../release-version.mjs";
import { trimForSummary } from "./shared.ts";
import { type CrossOsSuite, parseCrossOsSuiteFilter } from "./suite-filter.mjs";
@ -536,6 +540,42 @@ export function buildRealUpdateEnv(env: NodeJS.ProcessEnv) {
return updateEnv;
}
function isExtendedStableBaselineVersion(baselineVersion: string | undefined) {
const parsed = baselineVersion ? parseReleaseVersion(baselineVersion) : null;
return parsed !== null && classifyReleaseTrain(parsed) === "extended-stable";
}
function isExtendedStableCandidateVersion(candidateVersion: string | undefined) {
const parsed = candidateVersion ? parseReleaseVersion(candidateVersion) : null;
return parsed !== null && classifyReleaseTrain(parsed) === "extended-stable";
}
function usesExtendedStableRegistryRoute(
baselineVersion: string | undefined,
candidateVersion: string | undefined,
) {
return (
isExtendedStableBaselineVersion(baselineVersion) &&
isExtendedStableCandidateVersion(candidateVersion)
);
}
function buildPackagedUpgradeUpdateEnv(
env: NodeJS.ProcessEnv,
baselineVersion?: string,
candidateVersion?: string,
) {
const updateEnv = buildRealUpdateEnv(env);
if (usesExtendedStableRegistryRoute(baselineVersion, candidateVersion)) {
updateEnv.OPENCLAW_UPDATE_PACKAGE_SPEC = "openclaw";
// The shipped updater requires the bare package name to admit the loopback
// registry. Select its candidate tag only for the update process so a
// baseline specified as openclaw@latest still installs the published tag.
updateEnv.NPM_CONFIG_TAG = "extended-stable";
}
return updateEnv;
}
export function verifyPackagedUpgradeUpdateResult(
result: CommandResult,
_options?: { candidateVersion?: string },
@ -623,11 +663,14 @@ export function resolvePackagedUpgradeTimeouts(
export function buildPackagedUpgradeUpdateArgs(
candidateUrl: string,
timeoutSeconds = resolvePackagedUpgradeTimeouts(0).stepTimeoutSeconds,
baselineVersion?: string,
candidateVersion?: string,
) {
return [
"update",
"--tag",
candidateUrl,
...(usesExtendedStableRegistryRoute(baselineVersion, candidateVersion)
? []
: ["--tag", candidateUrl]),
"--yes",
"--json",
"--no-restart",
@ -636,6 +679,24 @@ export function buildPackagedUpgradeUpdateArgs(
];
}
export function buildPackagedUpgradeUpdateCommand(params: {
env: NodeJS.ProcessEnv;
candidateUrl: string;
candidateVersion: string;
timeoutSeconds?: number;
baselineVersion: string;
}) {
return {
env: buildPackagedUpgradeUpdateEnv(params.env, params.baselineVersion, params.candidateVersion),
args: buildPackagedUpgradeUpdateArgs(
params.candidateUrl,
params.timeoutSeconds,
params.baselineVersion,
params.candidateVersion,
),
};
}
export function isRecoverableWindowsPackagedUpgradeSwapCleanupFailure(
result: CommandResult | undefined,
platform = process.platform,
@ -663,7 +724,7 @@ export function isRecoverableWindowsPackagedUpgradeTimeoutError(
const message = error instanceof Error ? error.message : String(error);
return (
/\bCommand timed out:/u.test(message) &&
/[/\\]openclaw\.mjs update --tag http:\/\/127\.0\.0\.1:\d+\/openclaw[^/\s]*\.tgz --yes --json(?: --no-restart)? --timeout \d+/u.test(
/[/\\]openclaw\.mjs update(?: --tag http:\/\/127\.0\.0\.1:\d+\/openclaw[^/\s]*\.tgz)? --yes --json(?: --no-restart)? --timeout \d+/u.test(
message,
)
);

View file

@ -22,7 +22,7 @@ import type {
ProviderConfig,
} from "./config.ts";
import {
buildPackagedUpgradeUpdateArgs,
buildPackagedUpgradeUpdateCommand,
buildRealUpdateEnv,
isRecoverableWindowsPackagedUpgradeSwapCleanupFailure,
isRecoverableWindowsPackagedUpgradeTimeoutError,
@ -293,16 +293,20 @@ export async function runUpgradeLane(
});
}
const updateEnv = buildRealUpdateEnv(env);
const baselineInstallDurationMs = lane.phaseTimings.find(
(phase) => phase.name === "install-baseline",
)!.durationMs;
const updateTimeouts = resolvePackagedUpgradeTimeouts(baselineInstallDurationMs);
result.updateTimeouts = { baselineInstallDurationMs, ...updateTimeouts };
const updateArgs = buildPackagedUpgradeUpdateArgs(
params.candidateUrl,
updateTimeouts.stepTimeoutSeconds,
);
const updateCommand = buildPackagedUpgradeUpdateCommand({
env,
candidateUrl: params.candidateUrl,
candidateVersion: params.build.candidateVersion,
timeoutSeconds: updateTimeouts.stepTimeoutSeconds,
baselineVersion: baseline.version,
});
const updateEnv = updateCommand.env;
const updateArgs = updateCommand.args;
const updateLogPath = join(params.logsDir, "upgrade-update.log");
appendFileSync(
updateLogPath,

View file

@ -6,6 +6,8 @@ import { mkdirSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
bindCrossOsCandidateRootPackage,
omitCrossOsCandidateRootPackage,
resolveCrossOsPackageSet,
startCrossOsPackageRegistry,
} from "./lib/cross-os-release-checks/companions.ts";
@ -35,6 +37,7 @@ import {
} from "./lib/cross-os-release-checks/reporting.ts";
import { formatError } from "./lib/cross-os-release-checks/shared.ts";
import { isSupportedCrossOsSuite } from "./lib/cross-os-release-checks/suite-filter.mjs";
import { classifyReleaseTrain, parseReleaseVersion } from "./lib/release-version.mjs";
const SCRIPT_PATH = fileURLToPath(import.meta.url);
@ -210,7 +213,17 @@ async function main(argv: string[]) {
})
: { companions: [], packages: [] };
const { companions } = packageSet;
registry = await startCrossOsPackageRegistry(packageSet.packages, logsDir);
const parsedCandidateVersion = parseReleaseVersion(build.candidateVersion);
const registryPackages =
suite === "packaged-upgrade" &&
parsedCandidateVersion !== null &&
classifyReleaseTrain(parsedCandidateVersion) === "extended-stable"
? bindCrossOsCandidateRootPackage(packageSet.packages, {
version: build.candidateVersion,
tarballPath: build.candidateTgz,
})
: omitCrossOsCandidateRootPackage(packageSet.packages);
registry = await startCrossOsPackageRegistry(registryPackages, logsDir);
if (registry) {
process.env.NPM_CONFIG_REGISTRY = registry.url;
process.env.npm_config_registry = registry.url;

View file

@ -25,6 +25,7 @@ import {
buildCrossOsReleaseSmokeMemorySlotConfigArgs,
buildDiscordFetchInit,
buildPackagedUpgradeUpdateArgs,
buildPackagedUpgradeUpdateCommand,
buildReleaseOnboardArgs,
buildWindowsDevUpdateToolchainCheckScript,
buildWindowsFreshShellVersionCheckScript,
@ -1166,6 +1167,63 @@ describe("scripts/openclaw-cross-os-release-checks", () => {
expect(args.at(-2)).toBe("--timeout");
});
it.each([
{
label: "stable predecessor",
baselineVersion: "2026.8.32",
candidateVersion: "2026.8.34",
expectedArgs: [
"update",
"--tag",
"http://127.0.0.1:49152/openclaw-current.tgz",
"--yes",
"--json",
"--no-restart",
"--timeout",
"1200",
],
expectedPackageSpec: undefined,
expectedNpmTag: undefined,
},
{
label: "extended-stable predecessor and candidate",
baselineVersion: "2026.8.33",
candidateVersion: "2026.8.34",
expectedArgs: ["update", "--yes", "--json", "--no-restart", "--timeout", "1200"],
expectedPackageSpec: "openclaw",
expectedNpmTag: "extended-stable",
},
{
label: "extended-stable predecessor and regular candidate",
baselineVersion: "2026.8.33",
candidateVersion: "2026.9.1",
expectedArgs: [
"update",
"--tag",
"http://127.0.0.1:49152/openclaw-current.tgz",
"--yes",
"--json",
"--no-restart",
"--timeout",
"1200",
],
expectedPackageSpec: undefined,
expectedNpmTag: undefined,
},
])("routes packaged upgrades from the $label channel", (testCase) => {
const candidateUrl = "http://127.0.0.1:49152/openclaw-current.tgz";
const updateCommand = buildPackagedUpgradeUpdateCommand({
env: { NPM_CONFIG_REGISTRY: "http://127.0.0.1:49152" },
candidateUrl,
candidateVersion: testCase.candidateVersion,
timeoutSeconds: 1200,
baselineVersion: testCase.baselineVersion,
});
expect(updateCommand.args).toEqual(testCase.expectedArgs);
expect(updateCommand.env.OPENCLAW_UPDATE_PACKAGE_SPEC).toBe(testCase.expectedPackageSpec);
expect(updateCommand.env.NPM_CONFIG_TAG).toBe(testCase.expectedNpmTag);
});
it("uses forced shutdown only when the installed gateway supports it", () => {
const installedSource = readFileSync(
"scripts/lib/cross-os-release-checks/installed.ts",
@ -2341,6 +2399,14 @@ describe("scripts/openclaw-cross-os-release-checks", () => {
);
expect(isRecoverableWindowsPackagedUpgradeTimeoutError(error, "win32")).toBe(true);
expect(
isRecoverableWindowsPackagedUpgradeTimeoutError(
new Error(
"Command timed out: C:\\prefix\\node_modules\\openclaw\\openclaw.mjs update --yes --json --no-restart --timeout 1200",
),
"win32",
),
).toBe(true);
expect(
isRecoverableWindowsPackagedUpgradeTimeoutError(
new Error(

View file

@ -1,4 +1,4 @@
import { execFileSync, spawnSync } from "node:child_process";
import { execFile, execFileSync, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
mkdirSync,
@ -9,22 +9,33 @@ import {
unlinkSync,
writeFileSync,
} from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as packageArtifact from "../../scripts/e2e/parallels/package-artifact.ts";
import { packAndServeSmokeArtifact } from "../../scripts/e2e/parallels/smoke-common.ts";
import { resolveCrossOsPackageSet } from "../../scripts/lib/cross-os-release-checks/companions.ts";
import {
bindCrossOsCandidateRootPackage,
omitCrossOsCandidateRootPackage,
resolveCrossOsPackageSet,
resolveCrossOsRegistryDistTags,
startCrossOsPackageRegistry,
} from "../../scripts/lib/cross-os-release-checks/companions.ts";
import {
findLaneByName,
requiredPrepublishPluginPackagesForLanes,
} from "../../scripts/lib/docker-e2e-plan.mts";
import { resolveNpmRunner } from "../../scripts/npm-runner.mts";
import {
PREPUBLISH_PLUGIN_REGISTRY_MANIFEST,
createPrepublishPluginRegistryArtifact,
validatePrepublishPluginRegistryArtifact,
} from "../../scripts/prepublish-plugin-registry-artifact.mjs";
import { resolveExtendedStablePackage } from "../../src/infra/update-check.ts";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const SOURCE_SHA = "a".repeat(40);
const VERSION = "2026.8.1-beta.1";
@ -32,6 +43,7 @@ const PACKAGE_NAME = "@openclaw/discord";
const TARBALL = "openclaw-discord-2026.8.1-beta.1.tgz";
const SCRIPT = path.resolve("scripts/prepublish-plugin-registry-artifact.mjs");
const tempDirs: string[] = [];
const registryTempDirs = useAutoCleanupTempDirTracker(afterEach);
const packageTarballs = new Map<string, Buffer>();
const fixtureCommitArgs = [
"-c",
@ -44,6 +56,7 @@ const fixtureCommitArgs = [
"-m",
"test: seed release source",
];
const execFileAsync = promisify(execFile);
afterEach(() => {
vi.restoreAllMocks();
@ -56,21 +69,19 @@ function sha256(file: string): string {
return createHash("sha256").update(readFileSync(file)).digest("hex");
}
function writeFixtureTarball(root: string, tarballPath: string, name: string) {
const cached = packageTarballs.get(name);
function writeFixtureTarball(root: string, tarballPath: string, name: string, version = VERSION) {
const cacheKey = `${name}@${version}`;
const cached = packageTarballs.get(cacheKey);
if (cached) {
writeFileSync(tarballPath, cached);
return;
}
const packageRoot = path.join(root, "package");
mkdirSync(packageRoot, { recursive: true });
writeFileSync(
path.join(packageRoot, "package.json"),
`${JSON.stringify({ name, version: VERSION })}\n`,
);
writeFileSync(path.join(packageRoot, "package.json"), `${JSON.stringify({ name, version })}\n`);
execFileSync("tar", ["-czf", tarballPath, "-C", root, "package"]);
// Each test mutates its own file; only original archive bytes survive cleanup.
packageTarballs.set(name, readFileSync(tarballPath));
packageTarballs.set(cacheKey, readFileSync(tarballPath));
}
function fixture(packageName = PACKAGE_NAME) {
@ -270,6 +281,7 @@ describe("prepublish plugin registry artifact", () => {
"@openclaw/ai",
PACKAGE_NAME,
"@openclaw/gateway-protocol",
"openclaw",
]);
expect(crossOs.companions.map((entry) => entry.name)).toEqual([PACKAGE_NAME]);
});
@ -439,6 +451,134 @@ describe("prepublish plugin registry artifact", () => {
]);
});
it("publishes the candidate root under the extended-stable registry tag", () => {
expect(
resolveCrossOsRegistryDistTags([
{ name: "openclaw", version: "2026.8.34", tarballPath: "/tmp/openclaw.tgz" },
]),
).toBe("extended-stable=2026.8.34");
expect(
resolveCrossOsRegistryDistTags([
{ name: "openclaw", version: "2026.9.1", tarballPath: "/tmp/openclaw.tgz" },
]),
).toBeUndefined();
expect(
omitCrossOsCandidateRootPackage([
{ name: "@openclaw/codex", version: "2026.9.1", tarballPath: "/tmp/codex.tgz" },
{ name: "openclaw", version: "2026.9.1", tarballPath: "/tmp/openclaw.tgz" },
]),
).toEqual([{ name: "@openclaw/codex", version: "2026.9.1", tarballPath: "/tmp/codex.tgz" }]);
});
it("keeps the shipped updater and npm install on the local extended-stable candidate", async () => {
const candidateVersion = "2026.8.34";
const publishedVersion = "2026.8.33";
const root = registryTempDirs.make("openclaw-cross-os-registry-");
const tarballPath = path.join(root, `openclaw-${candidateVersion}.tgz`);
writeFixtureTarball(path.join(root, "archive"), tarballPath, "openclaw", candidateVersion);
const upstreamRequests: string[] = [];
const upstream = createServer((request, response) => {
upstreamRequests.push(request.url ?? "");
if (request.url !== "/openclaw") {
response.writeHead(404).end();
return;
}
const upstreamUrl = `http://127.0.0.1:${(upstream.address() as { port: number }).port}`;
response.writeHead(200, { "content-type": "application/json" });
response.end(
JSON.stringify({
name: "openclaw",
"dist-tags": { latest: publishedVersion },
versions: {
[publishedVersion]: {
name: "openclaw",
version: publishedVersion,
dist: { tarball: `${upstreamUrl}/openclaw/-/openclaw-${publishedVersion}.tgz` },
},
},
}),
);
});
await new Promise<void>((resolveListen, rejectListen) => {
upstream.once("error", rejectListen);
upstream.listen(0, "127.0.0.1", resolveListen);
});
const upstreamUrl = `http://127.0.0.1:${(upstream.address() as { port: number }).port}`;
let registry: Awaited<ReturnType<typeof startCrossOsPackageRegistry>>;
try {
const packages = bindCrossOsCandidateRootPackage([], {
version: candidateVersion,
tarballPath,
});
expect(resolveCrossOsRegistryDistTags(packages)).toBe(`extended-stable=${candidateVersion}`);
registry = await startCrossOsPackageRegistry(packages, root, {
upstreamRegistry: upstreamUrl,
});
expect(registry).toBeDefined();
const registryUrl = registry!.url;
const env = {
...process.env,
NPM_CONFIG_AUDIT: "false",
NPM_CONFIG_CACHE: path.join(root, "npm-cache"),
NPM_CONFIG_FUND: "false",
NPM_CONFIG_REGISTRY: registryUrl,
NPM_CONFIG_TAG: "extended-stable",
OPENCLAW_UPDATE_PACKAGE_SPEC: "openclaw",
};
const runNpm = async (npmArgs: string[]) => {
const runner = resolveNpmRunner({ env, npmArgs });
return await execFileAsync(runner.command, runner.args, {
encoding: "utf8",
env: runner.env ?? env,
timeout: 10_000,
windowsVerbatimArguments: runner.windowsVerbatimArguments,
});
};
await expect(
fetch(`${registryUrl}/openclaw/latest`).then(async (response) => await response.json()),
).resolves.toMatchObject({ name: "openclaw", version: publishedVersion });
await expect(
resolveExtendedStablePackage({ installKind: "package", env, timeoutMs: 5_000 }),
).resolves.toMatchObject({
status: "resolved",
selector: "extended-stable",
version: candidateVersion,
packageSpec: `openclaw@${candidateVersion}`,
});
const viewedVersion = JSON.parse(
(await runNpm(["view", "openclaw", "version", "--json"])).stdout,
);
expect(viewedVersion).toBe(candidateVersion);
const installPrefix = path.join(root, "installed");
await runNpm([
"install",
"--ignore-scripts",
"--no-package-lock",
"--prefix",
installPrefix,
"openclaw",
]);
expect(
JSON.parse(
readFileSync(path.join(installPrefix, "node_modules/openclaw/package.json"), "utf8"),
).version,
).toBe(candidateVersion);
expect(upstreamRequests).not.toContain("/openclaw/extended-stable");
expect(upstreamRequests).not.toContain(`/openclaw/${candidateVersion}`);
} finally {
try {
await registry?.close();
} finally {
await new Promise<void>((resolveClose, rejectClose) => {
upstream.close((error) => (error ? rejectClose(error) : resolveClose()));
});
}
}
}, 30_000);
it("rejects mismatched cross-OS companion registry identities", () => {
const paths = fixture();
const common = {