mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
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:
parent
1eaef188e4
commit
7a438dc93e
7 changed files with 404 additions and 28 deletions
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue