diff --git a/scripts/github/find-reusable-release-validation.sh b/scripts/github/find-reusable-release-validation.sh index f7ffeb747e82..834a47920ac1 100755 --- a/scripts/github/find-reusable-release-validation.sh +++ b/scripts/github/find-reusable-release-validation.sh @@ -161,9 +161,9 @@ if ! jq -e \ no_reuse "workflow SHA is not on trusted main lineage" fi -# Exact-target reuse still requires internally consistent version stamps -# (for example package.json must agree with the macOS plist). -if ! (cd "$REPO_DIR" && node "$PREFLIGHT" --macos-versions-only >&2); then +# Exact-target reuse still requires internally consistent npm version stamps. +# Native version metadata is outside the npm-only extended-stable contract. +if ! (cd "$REPO_DIR" && node "$PREFLIGHT" --npm-versions-only >&2); then no_reuse "target version metadata is inconsistent" fi diff --git a/scripts/release-preflight.mjs b/scripts/release-preflight.mjs index eef88f2f2af0..be586a3453af 100644 --- a/scripts/release-preflight.mjs +++ b/scripts/release-preflight.mjs @@ -1,10 +1,27 @@ #!/usr/bin/env node // Checks or refreshes generated release artifacts before a release publish. +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; import { runManagedCommand } from "./lib/managed-child-process.mjs"; +import { parseReleaseVersion } from "./lib/npm-publish-plan.mjs"; const parsedArgs = parseArgs(process.argv.slice(2)); const fix = parsedArgs.fix; +// Extended-stable evidence reuse validates npm-owned version stamps without +// importing the newer native-app release graph into the 6.x release branch. +if (parsedArgs.npmVersionsOnly) { + const errors = collectNpmVersionErrors(); + if (errors.length !== 0) { + for (const error of errors) { + console.error(`[release-preflight] npm version metadata: ${error}`); + } + process.exit(1); + } + console.log("[release-preflight] npm version metadata OK"); + process.exit(0); +} + const fixCommands = [ { name: "plugin versions", args: ["plugins:sync"] }, { name: "npm shrinkwraps", args: ["deps:shrinkwrap:changed:generate"] }, @@ -92,9 +109,62 @@ function printFailures(title, failures) { } } +function collectNpmVersionErrors(rootDir = resolve(".")) { + const packageJsonPath = resolve(rootDir, "package.json"); + let rootVersion; + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + rootVersion = typeof packageJson.version === "string" ? packageJson.version.trim() : ""; + } catch (error) { + return [`unable to read package.json: ${formatError(error)}`]; + } + if (!parseReleaseVersion(rootVersion)) { + return [`package.json has invalid release version ${JSON.stringify(rootVersion)}`]; + } + + const errors = []; + const extensionsDir = resolve(rootDir, "extensions"); + let entries; + try { + entries = readdirSync(extensionsDir, { withFileTypes: true }); + } catch (error) { + return [`unable to read extensions directory: ${formatError(error)}`]; + } + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const pluginPackagePath = resolve(extensionsDir, entry.name, "package.json"); + if (!existsSync(pluginPackagePath)) { + continue; + } + let packageJson; + try { + packageJson = JSON.parse(readFileSync(pluginPackagePath, "utf8")); + } catch (error) { + errors.push(`unable to read extensions/${entry.name}/package.json: ${formatError(error)}`); + continue; + } + if (packageJson.openclaw?.release?.publishToNpm !== true) { + continue; + } + if (packageJson.version !== rootVersion) { + errors.push( + `extensions/${entry.name}/package.json version is ${JSON.stringify(packageJson.version)}; expected ${JSON.stringify(rootVersion)}`, + ); + } + } + return errors; +} + +function formatError(error) { + return error instanceof Error ? error.message : String(error); +} + function parseArgs(argv) { let check = false; let wantsFix = false; + let npmVersionsOnly = false; for (const arg of argv) { if (arg === "--help") { printUsage(console.log); @@ -108,6 +178,10 @@ function parseArgs(argv) { wantsFix = true; continue; } + if (arg === "--npm-versions-only") { + npmVersionsOnly = true; + continue; + } console.error(`Unknown release preflight argument: ${arg}`); printUsage(console.error); process.exit(1); @@ -116,12 +190,18 @@ function parseArgs(argv) { console.error("Use either --fix or --check, not both."); process.exit(1); } - return { fix: wantsFix }; + if (npmVersionsOnly && (wantsFix || check)) { + console.error("Use --npm-versions-only without --fix or --check."); + process.exit(1); + } + return { fix: wantsFix, npmVersionsOnly }; } function printUsage(writeLine) { writeLine("Usage: node scripts/release-preflight.mjs [--check|--fix]"); + writeLine(" node scripts/release-preflight.mjs --npm-versions-only"); writeLine(""); writeLine(" --check verify generated release artifacts without writing changes (default)"); writeLine(" --fix refresh generated release artifacts, then verify them"); + writeLine(" --npm-versions-only verify root and publishable plugin versions, no commands"); } diff --git a/test/scripts/find-reusable-release-validation.test.ts b/test/scripts/find-reusable-release-validation.test.ts index fc75854cf4df..11bb0ce55b74 100644 --- a/test/scripts/find-reusable-release-validation.test.ts +++ b/test/scripts/find-reusable-release-validation.test.ts @@ -125,22 +125,7 @@ function commitFile(repo: string, filePath: string, content: string, message: st return git(repo, ["rev-parse", "HEAD"]); } -function plistFor(shortVersion: string, buildVersion: string): string { - return [ - '', - '', - "", - " CFBundleShortVersionString", - ` ${shortVersion}`, - " CFBundleVersion", - ` ${buildVersion}`, - "", - "", - "", - ].join("\n"); -} - -function createRepo(options: { plistBuildVersion?: string } = {}) { +function createRepo(options: { pluginVersion?: string } = {}) { const origin = tempDirs.make("evidence-reuse-origin-"); git(origin, ["init", "-q", "-b", "main"]); git(origin, ["config", "user.email", "test-user@example.invalid"]); @@ -150,10 +135,18 @@ function createRepo(options: { plistBuildVersion?: string } = {}) { join(origin, "package.json"), `${JSON.stringify({ name: "x", version: "2026.7.1" }, null, 2)}\n`, ); - mkdirSync(join(origin, "apps/macos/Sources/OpenClaw/Resources"), { recursive: true }); + mkdirSync(join(origin, "extensions/test-plugin"), { recursive: true }); writeFileSync( - join(origin, "apps/macos/Sources/OpenClaw/Resources/Info.plist"), - plistFor("2026.7.1", options.plistBuildVersion ?? "2026070100"), + join(origin, "extensions/test-plugin/package.json"), + `${JSON.stringify( + { + name: "@openclaw/test-plugin", + openclaw: { release: { publishToNpm: true } }, + version: options.pluginVersion ?? "2026.7.1", + }, + null, + 2, + )}\n`, ); mkdirSync(join(origin, "docs/install"), { recursive: true }); writeFileSync(join(origin, "docs/install/updating.md"), "# Updating\n"); @@ -851,7 +844,7 @@ describe("scripts/github/find-reusable-release-validation.sh", () => { }); it("rejects target version metadata that is internally inconsistent", () => { - const { origin, priorSha } = createRepo({ plistBuildVersion: "2026061000" }); + const { origin, priorSha } = createRepo({ pluginVersion: "2026.7.0" }); const clone = cloneHead(origin); const record = normalizedEvidence({ targetSha: priorSha }); const { binDir, fixtures, validatorPath } = setUpFixtures([{ record, runId: "111" }]); diff --git a/test/scripts/release-preflight.test.ts b/test/scripts/release-preflight.test.ts index 1bf6615e35fe..401d387d4fa1 100644 --- a/test/scripts/release-preflight.test.ts +++ b/test/scripts/release-preflight.test.ts @@ -92,6 +92,14 @@ describe("scripts/release-preflight.mjs", () => { expect(result.stdout).toBe(""); }); + it("checks npm version metadata without invoking package-manager commands", () => { + const result = runPreflight(["--npm-versions-only"], undefined, { PATH: "" }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("[release-preflight] npm version metadata OK"); + expect(result.stderr).toBe(""); + }); + it("runs every check command and reports all failed release artifact checks", () => { const fakePnpm = makeFakePnpm(); const result = runPreflight(["--check"], fakePnpm, {