feat(release): add atomic version preparation

This commit is contained in:
Vincent Koc
2026-07-10 15:40:33 -07:00
committed by Vincent Koc
parent e2e9b7db9f
commit 271b4a965c
4 changed files with 503 additions and 1 deletions
+1
View File
@@ -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",
+3 -1
View File
@@ -182,7 +182,9 @@ export function resolveAndroidVersion(rootDir = path.resolve(".")): ResolvedAndr
};
}
export function renderAndroidVersionProperties(version: ResolvedAndroidVersion): string {
export function renderAndroidVersionProperties(
version: Pick<ResolvedAndroidVersion, "canonicalVersion" | "versionCode">,
): 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`;
}
+288
View File
@@ -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<string, unknown>;
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<ReturnType<typeof parseReleaseVersion>>,
): 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(
`(<key>\\s*${escapedKey}\\s*</key>\\s*<string>)([^<]*)(</string>)`,
"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 <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;
}
}
+211
View File
@@ -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<string>();
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"),
[
"<plist>",
"<dict>",
" <key>CFBundleShortVersionString</key>",
" <string>2026.6.11</string>",
" <key>CFBundleVersion</key>",
" <string>2026061100</string>",
"</dict>",
"</plist>",
"",
].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<string, unknown> {
return JSON.parse(fs.readFileSync(filePath, "utf8")) as Record<string, unknown>;
}
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("<string>2026070200</string>");
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"),
"<plist><dict></dict></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);
});
});