From f7aea57edb8ac22591a67b3ddf99bd2d05656f2d Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Thu, 9 Jul 2026 06:18:49 -0700 Subject: [PATCH] fix(release): validate macOS source versions --- .../Sources/OpenClaw/Resources/Info.plist | 4 +- scripts/release-preflight.mjs | 95 +++++++++++++++- test/scripts/release-preflight.test.ts | 102 +++++++++++++++++- 3 files changed, 192 insertions(+), 9 deletions(-) diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist index ab36d314d53..40a80639570 100644 --- a/apps/macos/Sources/OpenClaw/Resources/Info.plist +++ b/apps/macos/Sources/OpenClaw/Resources/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.6.10 + 2026.7.1 CFBundleVersion - 2026061000 + 2026070100 CFBundleIconFile OpenClaw CFBundleURLTypes diff --git a/scripts/release-preflight.mjs b/scripts/release-preflight.mjs index eef88f2f2af..7a718e748df 100644 --- a/scripts/release-preflight.mjs +++ b/scripts/release-preflight.mjs @@ -1,9 +1,13 @@ #!/usr/bin/env node // Checks or refreshes generated release artifacts before a release publish. +import { readFileSync } 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; +const macosInfoPlistPath = "apps/macos/Sources/OpenClaw/Resources/Info.plist"; const fixCommands = [ { name: "plugin versions", args: ["plugins:sync"] }, @@ -39,16 +43,97 @@ if (fix) { } console.log("[release-preflight] checking release generated artifacts and manifests"); +console.log("\n[release-preflight] macOS app version metadata"); +const macosVersionErrors = collectMacosVersionErrors(); +if (macosVersionErrors.length === 0) { + console.log("[release-preflight] macOS app version metadata OK"); +} const failed = await runAll(checkCommands); -if (failed.length !== 0) { - printFailures("release preflight found drift", failed); +if (macosVersionErrors.length !== 0 || failed.length !== 0) { + console.error("\nrelease preflight found drift:"); + for (const error of macosVersionErrors) { + console.error(`- macOS app version metadata: ${error}`); + } + printCommandFailures(failed); console.error( - "\nRun `pnpm release:prep` if the version/config/API changes are intentional, then commit the generated files.", + "\nCorrect manual version metadata first. Run `pnpm release:prep` for intentional generated version/config/API changes, then commit the resulting files.", ); process.exit(1); } console.log("[release-preflight] OK"); +function collectMacosVersionErrors(rootDir = resolve(".")) { + const packageJsonPath = resolve(rootDir, "package.json"); + const infoPlistPath = resolve(rootDir, macosInfoPlistPath); + let packageVersion; + let infoPlist; + + try { + const parsedPackage = JSON.parse(readFileSync(packageJsonPath, "utf8")); + packageVersion = typeof parsedPackage.version === "string" ? parsedPackage.version.trim() : ""; + } catch (error) { + return [`unable to read package.json: ${formatError(error)}`]; + } + + const releaseVersion = parseReleaseVersion(packageVersion); + if (!releaseVersion) { + return [`package.json has invalid release version ${JSON.stringify(packageVersion)}`]; + } + + try { + infoPlist = readFileSync(infoPlistPath, "utf8"); + } catch (error) { + return [`unable to read ${macosInfoPlistPath}: ${formatError(error)}`]; + } + + const errors = []; + // The source plist tracks native base metadata. Packaging stamps the exact + // prerelease version and canonical Sparkle build into the copied app bundle. + const expectedShortVersion = releaseVersion.baseVersion; + const expectedBuildVersion = [ + String(releaseVersion.year), + String(releaseVersion.month).padStart(2, "0"), + String(releaseVersion.patch).padStart(2, "0"), + "00", + ].join(""); + const shortVersion = readPlistString(infoPlist, "CFBundleShortVersionString"); + const buildVersion = readPlistString(infoPlist, "CFBundleVersion"); + + if (shortVersion.error) { + errors.push(shortVersion.error); + } else if (shortVersion.value !== expectedShortVersion) { + errors.push( + `${macosInfoPlistPath} CFBundleShortVersionString is ${JSON.stringify(shortVersion.value)}; expected ${JSON.stringify(expectedShortVersion)} from package.json base version`, + ); + } + + if (buildVersion.error) { + errors.push(buildVersion.error); + } else if (buildVersion.value !== expectedBuildVersion) { + errors.push( + `${macosInfoPlistPath} CFBundleVersion is ${JSON.stringify(buildVersion.value)}; expected ${JSON.stringify(expectedBuildVersion)} for ${expectedShortVersion}`, + ); + } + + return errors; +} + +function readPlistString(infoPlist, key) { + const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = new RegExp(`\\s*${escapedKey}\\s*\\s*([^<]*)`, "gu"); + const matches = [...infoPlist.matchAll(pattern)]; + if (matches.length !== 1) { + return { + error: `${macosInfoPlistPath} must contain exactly one string value for ${key}; found ${matches.length}`, + }; + } + return { value: matches[0][1]?.trim() ?? "" }; +} + +function formatError(error) { + return error instanceof Error ? error.message : String(error); +} + async function runSerial(commands) { const failedValue = []; for (const command of commands) { @@ -87,6 +172,10 @@ async function runCommand(command) { function printFailures(title, failures) { console.error(`\n${title}:`); + printCommandFailures(failures); +} + +function printCommandFailures(failures) { for (const failure of failures) { console.error(`- ${failure.name}: exit ${failure.status} (pnpm ${failure.args.join(" ")})`); } diff --git a/test/scripts/release-preflight.test.ts b/test/scripts/release-preflight.test.ts index 1bf6615e35f..c199dd3bfe1 100644 --- a/test/scripts/release-preflight.test.ts +++ b/test/scripts/release-preflight.test.ts @@ -1,11 +1,11 @@ // Release preflight tests keep generated-artifact checks fail-closed for operators. -import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { delimiter, join } from "node:path"; import { spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { delimiter, join, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js"; -const SCRIPT = "scripts/release-preflight.mjs"; +const SCRIPT = resolve("scripts/release-preflight.mjs"); const CHECK_COMMANDS = [ "deps:root-ownership:check", "deps:shrinkwrap:check", @@ -61,9 +61,10 @@ function runPreflight( args: string[], fakePnpm?: ReturnType, extraEnv: NodeJS.ProcessEnv = {}, + cwd = process.cwd(), ) { return spawnSync(process.execPath, [SCRIPT, ...args], { - cwd: process.cwd(), + cwd, encoding: "utf8", env: { ...process.env, @@ -78,6 +79,36 @@ function runPreflight( }); } +function makeReleaseFixture( + params: { + buildVersion?: string; + packageVersion?: string; + shortVersion?: string; + } = {}, +): string { + const root = makeTempDir(tempDirs, "openclaw-release-preflight-fixture-"); + const plistDir = join(root, "apps", "macos", "Sources", "OpenClaw", "Resources"); + mkdirSync(plistDir, { recursive: true }); + writeFileSync( + join(root, "package.json"), + `${JSON.stringify({ version: params.packageVersion ?? "2026.7.1-beta.3" }, null, 2)}\n`, + ); + writeFileSync( + join(plistDir, "Info.plist"), + ` + + + CFBundleShortVersionString + ${params.shortVersion ?? "2026.7.1"} + CFBundleVersion + ${params.buildVersion ?? "2026070100"} + + +`, + ); + return root; +} + function readPnpmLog(logPath: string): string[] { return readFileSync(logPath, "utf8").trimEnd().split("\n").filter(Boolean); } @@ -123,4 +154,67 @@ describe("scripts/release-preflight.mjs", () => { "- npm shrinkwraps: exit 7 (pnpm deps:shrinkwrap:changed:generate)", ); }); + + it("accepts base macOS metadata for a beta package version", () => { + const fakePnpm = makeFakePnpm(); + const root = makeReleaseFixture(); + const result = runPreflight(["--check"], fakePnpm, {}, root); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("[release-preflight] macOS app version metadata OK"); + expect(readPnpmLog(fakePnpm.logPath)).toEqual(CHECK_COMMANDS); + }); + + it("reports stale macOS version and build metadata after running all checks", () => { + const fakePnpm = makeFakePnpm(); + const root = makeReleaseFixture({ + buildVersion: "2026061000", + shortVersion: "2026.6.10", + }); + const result = runPreflight(["--check"], fakePnpm, {}, root); + + expect(result.status).toBe(1); + expect(readPnpmLog(fakePnpm.logPath)).toEqual(CHECK_COMMANDS); + expect(result.stderr).toContain( + 'CFBundleShortVersionString is "2026.6.10"; expected "2026.7.1" from package.json base version', + ); + expect(result.stderr).toContain( + 'CFBundleVersion is "2026061000"; expected "2026070100" for 2026.7.1', + ); + expect(result.stderr).toContain("Correct manual version metadata first."); + }); + + it("fails closed when required macOS plist values are missing", () => { + const fakePnpm = makeFakePnpm(); + const root = makeReleaseFixture(); + const plistPath = join(root, "apps", "macos", "Sources", "OpenClaw", "Resources", "Info.plist"); + writeFileSync( + plistPath, + readFileSync(plistPath, "utf8").replace( + /\s*CFBundleVersion<\/key>\s*[^<]*<\/string>/u, + "", + ), + ); + const result = runPreflight(["--check"], fakePnpm, {}, root); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "Info.plist must contain exactly one string value for CFBundleVersion; found 0", + ); + }); + + it("keeps manual macOS metadata untouched in refresh mode", () => { + const fakePnpm = makeFakePnpm(); + const root = makeReleaseFixture({ + buildVersion: "2026061000", + shortVersion: "2026.6.10", + }); + const plistPath = join(root, "apps", "macos", "Sources", "OpenClaw", "Resources", "Info.plist"); + const before = readFileSync(plistPath, "utf8"); + const result = runPreflight(["--fix"], fakePnpm, {}, root); + + expect(result.status).toBe(1); + expect(readFileSync(plistPath, "utf8")).toBe(before); + expect(readPnpmLog(fakePnpm.logPath)).toEqual([...FIX_COMMANDS, ...CHECK_COMMANDS]); + }); });