fix(release): verify beta floor for core npm packages (#161968)

Share filesystem core package discovery with npm bundle preparation. Include every selected core package in beta-floor diagnostics and block postpublish on any core floor failure, while preserving reused and superseded core selectors.
This commit is contained in:
Peter Steinberger 2026-09-30 09:21:44 -07:00 • committed by GitHub
parent a3e3b0b9df
commit dc49c824d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 163 additions and 28 deletions

View file

@ -0,0 +1,32 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { readBoundedRegularFile } from "./actions-artifact-archive.mjs";
import CORE_PACKAGE_POLICY from "./npm-core-release-packages.json" with { type: "json" };
export { CORE_PACKAGE_POLICY };
function readManifest(directory) {
const path = join(directory, "package.json");
return JSON.parse(readBoundedRegularFile(path, { label: path, maxBytes: 1024 * 1024 }));
}
export function collectPublishableCorePackages(rootDir, root = readManifest(rootDir)) {
return CORE_PACKAGE_POLICY.filter((policy) => {
const directory = join(rootDir, policy.path);
if (
policy.dependency
? typeof root.dependencies?.[policy.dependency] !== "string"
: !existsSync(join(directory, "package.json"))
) {
return false;
}
const manifest = readManifest(directory);
if (!policy.dependency && manifest.openclaw?.release?.publishToNpm !== true) {
return false;
}
if (manifest.name !== policy.name || manifest.version !== root.version) {
throw new Error(`${policy.path}/package.json must publish ${policy.name}@${root.version}.`);
}
return true;
});
}

View file

@ -17,6 +17,7 @@ import { isRecord as isJsonRecord } from "../../packages/normalization-core/src/
import { normalizeOptionalString } from "../../packages/normalization-core/src/string-coerce.ts";
import { readPublicationArtifactArchive, sha256Digest } from "./actions-artifact-archive.mjs";
import { readBoundedResponseText } from "./bounded-response.mjs";
import { collectPublishableCorePackages } from "./npm-core-release-packages.mjs";
import { resolveNpmJsonEntries } from "./npm-json-output.mts";
import { npmRegistryReadbackDeadline } from "./npm-publish-plan.mjs";
import { collectClawHubPublishablePluginPackages } from "./plugin-clawhub-release.ts";
@ -1952,16 +1953,23 @@ export async function verifyBetaRelease(
diagnostic.start("coreNpm");
const openclawNpm = await verifyNpmPackage("openclaw", args.version, args.distTag);
diagnostic.observeNpmPublication({ stage: "coreNpm" });
const coreBetaFloorError = await readNpmBetaFloorError("openclaw", args.version);
if (coreBetaFloorError !== undefined) {
betaFloorErrors.push({ scope: { stage: "coreNpm" }, message: coreBetaFloorError });
diagnostic.fail(createNpmBetaFloorError([coreBetaFloorError]));
} else {
const corePackages = collectPublishableCorePackages(rootDir);
// Core versions can be reused without retagging; only enforce their beta floor.
for (const name of ["openclaw", ...corePackages.map((pkg) => pkg.name)]) {
const error = await readNpmBetaFloorError(name, args.version);
if (error !== undefined) {
betaFloorErrors.push({ scope: { stage: "coreNpm" }, message: error });
diagnostic.fail(createNpmBetaFloorError([error]));
}
}
const coreBetaFloorFailed = betaFloorErrors.length > 0;
if (!coreBetaFloorFailed) {
diagnostic.success("coreNpm");
lines.push(`openclaw npm OK: ${args.version} (${args.distTag})`);
lines.push(`core npm beta floors OK: ${corePackages.length}`);
}
if (!args.skipPostpublish && coreBetaFloorError === undefined) {
if (!args.skipPostpublish && !coreBetaFloorFailed) {
diagnostic.start("postpublish");
const postpublishVerifier = resolveOpenClawNpmPostpublishVerifier(
rootDir,

View file

@ -22,6 +22,10 @@ import {
inspectActionsArtifactZipWithPolicy,
readBoundedRegularFile,
} from "./lib/actions-artifact-archive.mjs";
import {
collectPublishableCorePackages,
CORE_PACKAGE_POLICY,
} from "./lib/npm-core-release-packages.mjs";
import { assertNpmShrinkwrapDependencies } from "./lib/npm-shrinkwrap-dependencies.mjs";
import { isRecord } from "./lib/record-shared.mjs";
import { resolveReleaseTagPackageIdentity } from "./lib/release-version.mjs";
@ -47,9 +51,6 @@ const CALLER_WORKFLOWS = new Set([
".github/workflows/full-release-candidate.yml",
".github/workflows/full-release-artifacts.yml",
]);
const CORE_PACKAGE_POLICY = JSON.parse(
readFileSync(new URL("./lib/npm-core-release-packages.json", import.meta.url), "utf8"),
);
const CORE_PACKAGES = CORE_PACKAGE_POLICY.map((entry) => entry.name);
const MAX_TARBALL_BYTES = 192 * 1024 * 1024;
const MAX_MANIFEST_BYTES = 1024 * 1024;
@ -859,24 +860,9 @@ export function prepareNpmPackageBundle({
),
};
};
const corePackageTarballs = CORE_PACKAGE_POLICY.flatMap((policy) => {
const packageName = policy.name;
const directory = join(sourceDir, policy.path);
if (policy.dependency) {
if (typeof root.dependencies?.[policy.dependency] !== "string") {
return [];
}
} else if (
!existsSync(join(directory, "package.json")) ||
readJson(join(directory, "package.json")).openclaw?.release?.publishToNpm !== true
) {
return [];
}
if (readJson(join(directory, "package.json")).version !== root.version) {
throw new Error(`Core package version mismatch: ${packageName}.`);
}
return [pack(directory, packageName)];
});
const corePackageTarballs = collectPublishableCorePackages(sourceDir, root).map((policy) =>
pack(join(sourceDir, policy.path), policy.name),
);
const aiPackage = corePackageTarballs.find(({ packageName }) => packageName === "@openclaw/ai");
const hasRootShrinkwrap = existsSync(join(sourceDir, "npm-shrinkwrap.json"));
if (aiPackage && hasRootShrinkwrap) {

View file

@ -120,6 +120,7 @@ const toolingPaths = [
"scripts/lib/release-publish-inputs.mjs",
"scripts/npm-preflight-tooling-identity.mjs",
"scripts/npm-prepared-bundle.mjs",
"scripts/lib/npm-core-release-packages.mjs",
"scripts/plugin-sdk-api-release-evidence.mjs",
"scripts/lib/plain-gh.mjs",
"scripts/lib/release-context.mjs",

View file

@ -4341,6 +4341,7 @@ function writePreflightConsumerTooling(toolingDir: string) {
"openclaw-npm-extended-stable-release.mjs",
"release-tooling-identity.mjs",
"lib/actions-artifact-archive.mjs",
"lib/npm-core-release-packages.mjs",
"lib/npm-core-release-packages.json",
"lib/npm-shrinkwrap-dependencies.mjs",
"lib/record-shared.mjs",

View file

@ -189,6 +189,9 @@ describe("verifyBetaRelease workflow outcomes", () => {
tags: Record<string, Record<string, string>>;
transientlyMissing?: string;
npm12?: boolean;
corePackages?: string[];
dependencies?: Record<string, string>;
emptyDistTags?: string;
} = {
version,
distTag: "beta",
@ -199,9 +202,27 @@ describe("verifyBetaRelease workflow outcomes", () => {
const binDir = join(rootDir, "bin");
mkdirSync(binDir);
mkdirSync(join(rootDir, "extensions"));
writeFileSync(join(rootDir, "package.json"), JSON.stringify({ version: npm.version }));
writeFileSync(
join(rootDir, "package.json"),
JSON.stringify({ version: npm.version, dependencies: npm.dependencies }),
);
writeFileSync(join(binDir, "npm.json"), JSON.stringify(npm));
for (const name of npm.corePackages ?? []) {
const directory = join(rootDir, "packages", name.slice("@openclaw/".length));
mkdirSync(directory, { recursive: true });
writeFileSync(
join(directory, "package.json"),
JSON.stringify({
name,
version: npm.version,
openclaw: { release: { publishToNpm: true } },
}),
);
}
for (const name of Object.keys(npm.tags).filter((packageName) => packageName !== "openclaw")) {
if (npm.corePackages?.includes(name)) {
continue;
}
writePublishablePluginFixture(rootDir, {
extensionId: name.slice("@openclaw/".length),
packageName: name,
@ -242,6 +263,7 @@ if (path.basename(process.argv[1]) === "npm" && args[0] === "view") {
const name = Object.keys(npm.tags).find((name) => args[1] === name || args[1] === name + "@" + npm.version);
if (!name) throw new Error("Unexpected npm package: " + args[1]);
if (args[2] === "dist-tags") {
if (npm.emptyDistTags === name) process.exit(0);
const visible = path.join(path.dirname(process.argv[1]), "npm-visible");
if (npm.transientlyMissing === name && !fs.existsSync(visible)) {
fs.writeFileSync(visible, "ready");
@ -709,8 +731,10 @@ syncBuiltinESMExports();
const fixture = workflowFixture({}, true, undefined, {
version: latest,
distTag: "latest",
corePackages: ["@openclaw/gateway-client"],
tags: {
openclaw: { latest, beta: "2026.9.1" },
"@openclaw/gateway-client": { latest, beta: "2026.9.1" },
"@openclaw/demo": { latest, beta: "2026.9.3-beta.1" },
"@openclaw/other": { latest },
},
@ -719,12 +743,95 @@ syncBuiltinESMExports();
const verification = verifyBetaRelease(fixture.args, { rootDir: fixture.rootDir });
await expect(verification).rejects.toThrow(
"openclaw: beta=2026.9.1, latest=2026.9.3\n" +
"@openclaw/gateway-client: beta=2026.9.1, latest=2026.9.3\n" +
"@openclaw/demo: beta=2026.9.3-beta.1, latest=2026.9.3\n" +
"@openclaw/other: beta=<missing>, latest=2026.9.3",
);
await expect(verification).rejects.toThrow("npm dist-tag add <pkg>@<latest> beta");
});
it.each([false, true])(
"rejects a core package with invalid beta readback (empty: %s)",
async (empty) => {
const latest = "2026.9.7";
const name = "@openclaw/gateway-client";
const fixture = workflowFixture({}, true, undefined, {
version: latest,
distTag: "latest",
corePackages: [name],
emptyDistTags: empty ? name : undefined,
tags: {
openclaw: { latest, beta: latest },
[name]: { latest, beta: "2026.9.3" },
},
});
fixture.args.skipPostpublish = false;
await expect(verifyBetaRelease(fixture.args, { rootDir: fixture.rootDir })).rejects.toThrow(
empty
? `npm view ${name}@${latest} dist-tags returned invalid JSON`
: `${name}: beta=2026.9.3, latest=${latest}`,
);
expect(
JSON.parse(
readFileSync(join(fixture.rootDir, "release-postpublish-diagnostics.json"), "utf8"),
),
).toMatchObject({
stages: { coreNpm: { state: "failure" }, postpublish: { state: "unattempted" } },
});
expect(existsSync(join(fixture.rootDir, "evidence.json"))).toBe(false);
},
);
it.each([
{ version: "2026.9.3", distTag: "beta", dependsOnAi: true },
{ version: "2026.8.33", distTag: "extended-stable", dependsOnAi: false },
])(
"verifies core beta floors with superseded or absent $distTag selectors",
async ({ version: releaseVersion, distTag, dependsOnAi }) => {
const corePackages = [
"@openclaw/ai",
"@openclaw/gateway-protocol",
"@openclaw/gateway-client",
];
const fixture = workflowFixture({}, true, undefined, {
version: releaseVersion,
distTag,
corePackages,
dependencies: dependsOnAi ? { "@openclaw/ai": releaseVersion } : {},
tags: {
openclaw: { [distTag]: releaseVersion },
"@openclaw/ai": { latest: "2026.9.7", beta: dependsOnAi ? "2026.9.7" : "2026.9.3" },
"@openclaw/gateway-protocol": { latest: "2026.9.7", beta: "2026.9.8-beta.1" },
"@openclaw/gateway-client": { beta: "2026.9.8-beta.1" },
},
});
const lines = await verifyBetaRelease(fixture.args, { rootDir: fixture.rootDir });
expect(lines).toContain(`core npm beta floors OK: ${dependsOnAi ? 3 : 2}`);
const commands: string[][] = readFileSync(join(fixture.binDir, "commands.jsonl"), "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line));
expect(
commands.filter(
([command, , selector]) => command === "npm" && selector?.startsWith("@openclaw/"),
),
).toEqual(
corePackages
.filter((name) => dependsOnAi || name !== "@openclaw/ai")
.map((name) => [
"npm",
"view",
`${name}@${releaseVersion}`,
"dist-tags",
"--json",
"--prefer-online",
]),
);
},
);
it.each([false, true])(
"queries a beta-only plugin without latest (npm 12 and initial E404: %s)",
async (transientlyMissing) => {