From dc49c824d3e958732ffec1e86de84d82944e9309 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 09:21:44 -0700 Subject: [PATCH] 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. --- scripts/lib/npm-core-release-packages.mjs | 32 +++++ scripts/lib/release-beta-verifier.ts | 20 +++- scripts/npm-prepared-bundle.mjs | 28 ++--- ...full-release-publication-admission.test.ts | 1 + .../package-acceptance-workflow.test.ts | 1 + test/scripts/release-beta-verifier.test.ts | 109 +++++++++++++++++- 6 files changed, 163 insertions(+), 28 deletions(-) create mode 100644 scripts/lib/npm-core-release-packages.mjs diff --git a/scripts/lib/npm-core-release-packages.mjs b/scripts/lib/npm-core-release-packages.mjs new file mode 100644 index 000000000000..d88804fea240 --- /dev/null +++ b/scripts/lib/npm-core-release-packages.mjs @@ -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; + }); +} diff --git a/scripts/lib/release-beta-verifier.ts b/scripts/lib/release-beta-verifier.ts index 2decdc6ce277..4c48cf9a367c 100644 --- a/scripts/lib/release-beta-verifier.ts +++ b/scripts/lib/release-beta-verifier.ts @@ -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, diff --git a/scripts/npm-prepared-bundle.mjs b/scripts/npm-prepared-bundle.mjs index 8fc34640bcee..6ccfc9b0cb21 100644 --- a/scripts/npm-prepared-bundle.mjs +++ b/scripts/npm-prepared-bundle.mjs @@ -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) { diff --git a/test/scripts/full-release-publication-admission.test.ts b/test/scripts/full-release-publication-admission.test.ts index 5c71c6584ec6..1cef2c43913a 100644 --- a/test/scripts/full-release-publication-admission.test.ts +++ b/test/scripts/full-release-publication-admission.test.ts @@ -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", diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 1ceb7fbe20a3..0823f497deaa 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -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", diff --git a/test/scripts/release-beta-verifier.test.ts b/test/scripts/release-beta-verifier.test.ts index 3c8e8d6f9b8e..eb141ce82d4b 100644 --- a/test/scripts/release-beta-verifier.test.ts +++ b/test/scripts/release-beta-verifier.test.ts @@ -189,6 +189,9 @@ describe("verifyBetaRelease workflow outcomes", () => { tags: Record>; transientlyMissing?: string; npm12?: boolean; + corePackages?: string[]; + dependencies?: Record; + 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=, latest=2026.9.3", ); await expect(verification).rejects.toThrow("npm dist-tag add @ 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) => {