diff --git a/package.json b/package.json index 5771ef1a0768..27395caba1dd 100644 --- a/package.json +++ b/package.json @@ -1766,6 +1766,7 @@ "release:plugins:npm:plan": "node --import tsx scripts/plugin-npm-release-plan.ts", "release:verify-beta": "node --import tsx scripts/release-verify-beta.ts", "release:prep": "node scripts/release-preflight.mjs --fix", + "release:version": "node --import tsx scripts/release-version.ts", "runtime-sidecars:check": "node --import tsx scripts/generate-runtime-sidecar-paths-baseline.ts --check", "runtime-sidecars:gen": "node --import tsx scripts/generate-runtime-sidecar-paths-baseline.ts --write", "start": "node openclaw.mjs", diff --git a/scripts/lib/android-version.ts b/scripts/lib/android-version.ts index 0afcba4afbc7..96059c428f4b 100644 --- a/scripts/lib/android-version.ts +++ b/scripts/lib/android-version.ts @@ -182,7 +182,9 @@ export function resolveAndroidVersion(rootDir = path.resolve(".")): ResolvedAndr }; } -export function renderAndroidVersionProperties(version: ResolvedAndroidVersion): string { +export function renderAndroidVersionProperties( + version: Pick, +): string { return `# Shared Android version defaults.\n# Source of truth: apps/android/version.json\n# Generated by scripts/android-sync-versioning.ts.\n\nOPENCLAW_ANDROID_VERSION_NAME=${version.canonicalVersion}\nOPENCLAW_ANDROID_VERSION_CODE=${version.versionCode}\n`; } diff --git a/scripts/release-version.ts b/scripts/release-version.ts new file mode 100644 index 000000000000..fcc84cfb9cf7 --- /dev/null +++ b/scripts/release-version.ts @@ -0,0 +1,288 @@ +// Release Version keeps the core and explicitly selected native release trains aligned. +import fs from "node:fs"; +import path from "node:path"; +import { + canonicalAndroidVersionCode, + normalizeAndroidVersionCode, + normalizePinnedAndroidVersion, + renderAndroidVersionProperties, +} from "./lib/android-version.ts"; +import { parseReleaseVersion } from "./lib/npm-publish-plan.mjs"; + +const MACOS_INFO_PLIST = "apps/macos/Sources/OpenClaw/Resources/Info.plist"; +const ANDROID_VERSION_FILE = "apps/android/version.json"; +const ANDROID_VERSION_PROPERTIES_FILE = "apps/android/Config/Version.properties"; + +type ReleaseVersionMode = "check" | "write"; + +type ReleaseVersionArgs = { + android: boolean; + help: boolean; + mode: ReleaseVersionMode; + rootDir: string; + version: string | null; +}; + +type ReleaseVersionChange = { + currentContent: string; + nextContent: string; + path: string; +}; + +type ReleaseVersionPlan = { + changes: ReleaseVersionChange[]; + version: string; +}; + +type AndroidVersionManifest = { + version?: unknown; + versionCode?: unknown; +}; + +export function parseReleaseVersionArgs(argv: string[]): ReleaseVersionArgs { + let android = false; + let help = false; + let mode: ReleaseVersionMode = "check"; + let rootDir = path.resolve("."); + let version: string | null = null; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case "--": { + break; + } + case "--android": { + android = true; + break; + } + case "--check": { + mode = "check"; + break; + } + case "--root": { + rootDir = path.resolve(readOptionValue(argv, index, arg)); + index += 1; + break; + } + case "--version": { + version = readOptionValue(argv, index, arg); + index += 1; + break; + } + case "--write": { + mode = "write"; + break; + } + case "-h": + case "--help": { + help = true; + break; + } + default: { + throw new Error(`Unknown argument: ${arg}`); + } + } + } + + return { android, help, mode, rootDir, version }; +} + +export function planReleaseVersion(params: { + android?: boolean; + rootDir?: string; + version: string; +}): ReleaseVersionPlan { + const rootDir = path.resolve(params.rootDir ?? "."); + const parsedVersion = parseReleaseVersion(params.version); + if (!parsedVersion) { + throw new Error( + `Invalid release version '${params.version}'. Expected YYYY.M.PATCH, YYYY.M.PATCH-alpha.N, YYYY.M.PATCH-beta.N, or YYYY.M.PATCH-N.`, + ); + } + + const changes = [ + planPackageJson(rootDir, parsedVersion.version), + planMacosInfoPlist(rootDir, parsedVersion), + ]; + if (params.android) { + changes.push(...planAndroidVersion(rootDir, parsedVersion.baseVersion)); + } + + return { + changes: changes.filter((change) => change.currentContent !== change.nextContent), + version: parsedVersion.version, + }; +} + +export function applyReleaseVersionPlan(plan: ReleaseVersionPlan): void { + const tempPaths: string[] = []; + try { + for (const [index, change] of plan.changes.entries()) { + const tempPath = `${change.path}.release-version-${process.pid}-${index}.tmp`; + fs.writeFileSync(tempPath, change.nextContent, "utf8"); + tempPaths.push(tempPath); + } + for (const [index, change] of plan.changes.entries()) { + fs.renameSync(tempPaths[index], change.path); + } + } finally { + for (const tempPath of tempPaths) { + fs.rmSync(tempPath, { force: true }); + } + } +} + +export function main(argv = process.argv.slice(2)): number { + const args = parseReleaseVersionArgs(argv); + if (args.help) { + printUsage(); + return 0; + } + if (!args.version) { + throw new Error("Missing required --version."); + } + + const plan = planReleaseVersion({ + android: args.android, + rootDir: args.rootDir, + version: args.version, + }); + if (plan.changes.length === 0) { + process.stdout.write(`Release version ${plan.version} is already aligned.\n`); + return 0; + } + + const relativePaths = plan.changes.map((change) => path.relative(args.rootDir, change.path)); + if (args.mode === "check") { + process.stderr.write( + `Release version ${plan.version} requires updates:\n- ${relativePaths.join("\n- ")}\n`, + ); + return 1; + } + + applyReleaseVersionPlan(plan); + process.stdout.write( + `Updated release version ${plan.version}:\n- ${relativePaths.join("\n- ")}\n`, + ); + return 0; +} + +function planPackageJson(rootDir: string, version: string): ReleaseVersionChange { + const filePath = path.join(rootDir, "package.json"); + const currentContent = fs.readFileSync(filePath, "utf8"); + const packageJson = JSON.parse(currentContent) as Record; + if (typeof packageJson.version !== "string" || !packageJson.version.trim()) { + throw new Error(`Missing package.json version in ${filePath}.`); + } + packageJson.version = version; + return { + currentContent, + nextContent: `${JSON.stringify(packageJson, null, 2)}\n`, + path: filePath, + }; +} + +function planMacosInfoPlist( + rootDir: string, + releaseVersion: NonNullable>, +): ReleaseVersionChange { + const filePath = path.join(rootDir, MACOS_INFO_PLIST); + const currentContent = fs.readFileSync(filePath, "utf8"); + const buildVersion = [ + String(releaseVersion.year), + String(releaseVersion.month).padStart(2, "0"), + String(releaseVersion.patch).padStart(2, "0"), + "00", + ].join(""); + const shortVersionContent = replacePlistString( + currentContent, + "CFBundleShortVersionString", + releaseVersion.baseVersion, + MACOS_INFO_PLIST, + ); + const nextContent = replacePlistString( + shortVersionContent, + "CFBundleVersion", + buildVersion, + MACOS_INFO_PLIST, + ); + return { currentContent, nextContent, path: filePath }; +} + +function planAndroidVersion(rootDir: string, baseVersion: string): ReleaseVersionChange[] { + const versionPath = path.join(rootDir, ANDROID_VERSION_FILE); + const propertiesPath = path.join(rootDir, ANDROID_VERSION_PROPERTIES_FILE); + const versionContent = fs.readFileSync(versionPath, "utf8"); + const propertiesContent = fs.readFileSync(propertiesPath, "utf8"); + const manifest = JSON.parse(versionContent) as AndroidVersionManifest; + const currentVersion = + typeof manifest.version === "string" ? normalizePinnedAndroidVersion(manifest.version) : null; + const currentVersionCode = + typeof manifest.versionCode === "number" ? manifest.versionCode : Number.NaN; + const versionCode = + currentVersion === baseVersion + ? normalizeAndroidVersionCode(currentVersionCode, baseVersion) + : canonicalAndroidVersionCode(baseVersion); + const nextVersionContent = `${JSON.stringify({ version: baseVersion, versionCode }, null, 2)}\n`; + const nextPropertiesContent = renderAndroidVersionProperties({ + canonicalVersion: baseVersion, + versionCode, + }); + + return [ + { + currentContent: versionContent, + nextContent: nextVersionContent, + path: versionPath, + }, + { + currentContent: propertiesContent, + nextContent: nextPropertiesContent, + path: propertiesPath, + }, + ]; +} + +function replacePlistString(content: string, key: string, value: string, filePath: string): string { + const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = new RegExp( + `(\\s*${escapedKey}\\s*\\s*)([^<]*)()`, + "gu", + ); + const matches = [...content.matchAll(pattern)]; + if (matches.length !== 1) { + throw new Error(`${filePath} must contain exactly one string value for ${key}.`); + } + return content.replace(pattern, `$1${value}$3`); +} + +function readOptionValue(argv: string[], index: number, flag: string): string { + const value = argv[index + 1]; + if (!value || value.startsWith("-")) { + throw new Error(`Missing value for ${flag}.`); + } + return value; +} + +function printUsage(): void { + process.stdout.write( + [ + "Usage: node --import tsx scripts/release-version.ts --version [--check|--write] [--android] [--root dir]", + "", + " --check report release version drift without writing (default)", + " --write update all selected version files after validating them", + " --android also align the independently pinned Android release train", + "", + ].join("\n"), + ); +} + +if (import.meta.main) { + try { + process.exitCode = main(); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/test/scripts/release-version.test.ts b/test/scripts/release-version.test.ts new file mode 100644 index 000000000000..ebdc85242c7d --- /dev/null +++ b/test/scripts/release-version.test.ts @@ -0,0 +1,211 @@ +// Release version tests cover one-command core and native version alignment. +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + applyReleaseVersionPlan, + parseReleaseVersionArgs, + planReleaseVersion, +} from "../../scripts/release-version.ts"; +import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js"; + +const SCRIPT = path.resolve("scripts/release-version.ts"); +const tempDirs = new Set(); + +afterEach(() => { + cleanupTempDirs(tempDirs); +}); + +function writeFixture(params?: { + androidVersion?: string; + androidVersionCode?: number; + packageVersion?: string; +}): string { + const root = makeTempDir(tempDirs, "openclaw-release-version-"); + fs.mkdirSync(path.join(root, "apps", "macos", "Sources", "OpenClaw", "Resources"), { + recursive: true, + }); + fs.mkdirSync(path.join(root, "apps", "android", "Config"), { recursive: true }); + fs.writeFileSync( + path.join(root, "package.json"), + `${JSON.stringify( + { + name: "openclaw", + version: params?.packageVersion ?? "2026.6.11", + private: true, + }, + null, + 2, + )}\n`, + ); + fs.writeFileSync( + path.join(root, "apps", "macos", "Sources", "OpenClaw", "Resources", "Info.plist"), + [ + "", + "", + " CFBundleShortVersionString", + " 2026.6.11", + " CFBundleVersion", + " 2026061100", + "", + "", + "", + ].join("\n"), + ); + fs.writeFileSync( + path.join(root, "apps", "android", "version.json"), + `${JSON.stringify( + { + version: params?.androidVersion ?? "2026.7.1", + versionCode: params?.androidVersionCode ?? 2026070102, + }, + null, + 2, + )}\n`, + ); + fs.writeFileSync(path.join(root, "apps", "android", "Config", "Version.properties"), "stale\n"); + return root; +} + +function readJson(filePath: string): Record { + return JSON.parse(fs.readFileSync(filePath, "utf8")) as Record; +} + +describe("release version argument parsing", () => { + it("defaults to check mode and keeps Android opt-in", () => { + expect(parseReleaseVersionArgs(["--version", "2026.7.2-beta.1"])).toMatchObject({ + android: false, + mode: "check", + version: "2026.7.2-beta.1", + }); + }); +}); + +describe("release version planning", () => { + it("aligns root and macOS metadata to a prerelease without moving Android", () => { + const root = writeFixture(); + const plan = planReleaseVersion({ + rootDir: root, + version: "2026.7.2-beta.1", + }); + + expect(plan.changes.map((change) => path.relative(root, change.path))).toEqual([ + "package.json", + "apps/macos/Sources/OpenClaw/Resources/Info.plist", + ]); + applyReleaseVersionPlan(plan); + + expect(readJson(path.join(root, "package.json"))).toMatchObject({ + name: "openclaw", + private: true, + version: "2026.7.2-beta.1", + }); + expect( + fs.readFileSync( + path.join(root, "apps", "macos", "Sources", "OpenClaw", "Resources", "Info.plist"), + "utf8", + ), + ).toContain("2026070200"); + expect(readJson(path.join(root, "apps", "android", "version.json"))).toMatchObject({ + version: "2026.7.1", + versionCode: 2026070102, + }); + }); + + it("keeps an existing Android build increment on the same release train", () => { + const root = writeFixture(); + const plan = planReleaseVersion({ + android: true, + rootDir: root, + version: "2026.7.1-beta.4", + }); + applyReleaseVersionPlan(plan); + + expect(readJson(path.join(root, "apps", "android", "version.json"))).toEqual({ + version: "2026.7.1", + versionCode: 2026070102, + }); + expect( + fs.readFileSync(path.join(root, "apps", "android", "Config", "Version.properties"), "utf8"), + ).toContain("OPENCLAW_ANDROID_VERSION_CODE=2026070102"); + }); + + it("starts a new Android train at its canonical build code", () => { + const root = writeFixture(); + const plan = planReleaseVersion({ + android: true, + rootDir: root, + version: "2026.7.2-3", + }); + applyReleaseVersionPlan(plan); + + expect(readJson(path.join(root, "apps", "android", "version.json"))).toEqual({ + version: "2026.7.2", + versionCode: 2026070201, + }); + }); + + it("validates every selected file before writing any changes", () => { + const root = writeFixture(); + const packagePath = path.join(root, "package.json"); + const before = fs.readFileSync(packagePath, "utf8"); + fs.writeFileSync( + path.join(root, "apps", "macos", "Sources", "OpenClaw", "Resources", "Info.plist"), + "\n", + ); + + expect(() => + planReleaseVersion({ + rootDir: root, + version: "2026.7.2-beta.1", + }), + ).toThrow("must contain exactly one string value for CFBundleShortVersionString"); + expect(fs.readFileSync(packagePath, "utf8")).toBe(before); + }); +}); + +describe("release version CLI", () => { + it("reports drift in check mode, writes it once, then passes", () => { + const root = writeFixture(); + const check = spawnSync( + process.execPath, + ["--import", "tsx", SCRIPT, "--root", root, "--version", "2026.7.2-beta.1"], + { encoding: "utf8" }, + ); + expect(check.status).toBe(1); + expect(check.stderr).toContain("Release version 2026.7.2-beta.1 requires updates:"); + expect(check.stderr).toContain("- package.json"); + + const write = spawnSync( + process.execPath, + ["--import", "tsx", SCRIPT, "--root", root, "--version", "2026.7.2-beta.1", "--write"], + { encoding: "utf8" }, + ); + expect(write.status).toBe(0); + expect(write.stdout).toContain("Updated release version 2026.7.2-beta.1:"); + + const recheck = spawnSync( + process.execPath, + ["--import", "tsx", SCRIPT, "--root", root, "--version", "2026.7.2-beta.1"], + { encoding: "utf8" }, + ); + expect(recheck.status).toBe(0); + expect(recheck.stdout).toBe("Release version 2026.7.2-beta.1 is already aligned.\n"); + }); + + it("rejects invalid versions without changing the fixture", () => { + const root = writeFixture(); + const packagePath = path.join(root, "package.json"); + const before = fs.readFileSync(packagePath, "utf8"); + const result = spawnSync( + process.execPath, + ["--import", "tsx", SCRIPT, "--root", root, "--version", "7.2.0", "--write"], + { encoding: "utf8" }, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Invalid release version '7.2.0'"); + expect(fs.readFileSync(packagePath, "utf8")).toBe(before); + }); +});