mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 12:23:27 +02:00
* feat(ios): decouple app versioning from gateway * feat(ios): pin calver release versioning * refactor(ios): drop prerelease version helper fields * docs(changelog): note pinned ios release versioning (#63001) (thanks @ngutman)
219 lines
7.0 KiB
TypeScript
219 lines
7.0 KiB
TypeScript
import { readFileSync, writeFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
|
|
export const IOS_VERSION_FILE = "apps/ios/version.json";
|
|
export const IOS_CHANGELOG_FILE = "apps/ios/CHANGELOG.md";
|
|
export const IOS_VERSION_XCCONFIG_FILE = "apps/ios/Config/Version.xcconfig";
|
|
export const IOS_RELEASE_NOTES_FILE = "apps/ios/fastlane/metadata/en-US/release_notes.txt";
|
|
|
|
const PINNED_IOS_VERSION_PATTERN = /^(\d{4}\.\d{1,2}\.\d{1,2})$/u;
|
|
const GATEWAY_VERSION_PATTERN = /^(\d{4}\.\d{1,2}\.\d{1,2})(?:-(?:beta\.\d+|\d+))?$/u;
|
|
|
|
export type IosVersionManifest = {
|
|
version: string;
|
|
};
|
|
|
|
export type ResolvedIosVersion = {
|
|
canonicalVersion: string;
|
|
marketingVersion: string;
|
|
buildVersion: string;
|
|
versionFilePath: string;
|
|
changelogPath: string;
|
|
versionXcconfigPath: string;
|
|
releaseNotesPath: string;
|
|
};
|
|
|
|
export type SyncIosVersioningMode = "check" | "write";
|
|
|
|
function normalizeTrailingNewline(value: string): string {
|
|
return value.endsWith("\n") ? value : `${value}\n`;
|
|
}
|
|
|
|
export function normalizePinnedIosVersion(rawVersion: string): string {
|
|
const trimmed = rawVersion.trim();
|
|
if (!trimmed) {
|
|
throw new Error(`Missing iOS version in ${IOS_VERSION_FILE}.`);
|
|
}
|
|
|
|
const match = PINNED_IOS_VERSION_PATTERN.exec(trimmed);
|
|
if (!match) {
|
|
throw new Error(`Invalid iOS version '${rawVersion}'. Expected pinned CalVer like 2026.4.6.`);
|
|
}
|
|
|
|
return match[1] ?? trimmed;
|
|
}
|
|
|
|
export function normalizeGatewayVersionToPinnedIosVersion(rawVersion: string): string {
|
|
const trimmed = rawVersion.trim().replace(/^v/u, "");
|
|
if (!trimmed) {
|
|
throw new Error("Missing root package.json version.");
|
|
}
|
|
|
|
const match = GATEWAY_VERSION_PATTERN.exec(trimmed);
|
|
if (!match) {
|
|
throw new Error(
|
|
`Invalid gateway version '${rawVersion}'. Expected YYYY.M.D, YYYY.M.D-beta.N, or YYYY.M.D-N.`,
|
|
);
|
|
}
|
|
|
|
return match[1] ?? trimmed;
|
|
}
|
|
|
|
export function readRootPackageVersion(rootDir = path.resolve(".")): string {
|
|
const packageJsonPath = path.join(rootDir, "package.json");
|
|
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
|
|
const version = typeof parsed.version === "string" ? parsed.version.trim() : "";
|
|
if (!version) {
|
|
throw new Error(`Missing package.json version in ${packageJsonPath}.`);
|
|
}
|
|
return version;
|
|
}
|
|
|
|
export function resolveGatewayVersionForIosRelease(rootDir = path.resolve(".")): {
|
|
packageVersion: string;
|
|
pinnedIosVersion: string;
|
|
} {
|
|
const packageVersion = readRootPackageVersion(rootDir);
|
|
return {
|
|
packageVersion,
|
|
pinnedIosVersion: normalizeGatewayVersionToPinnedIosVersion(packageVersion),
|
|
};
|
|
}
|
|
|
|
export function readIosVersionManifest(rootDir = path.resolve(".")): IosVersionManifest {
|
|
const versionFilePath = path.join(rootDir, IOS_VERSION_FILE);
|
|
return JSON.parse(readFileSync(versionFilePath, "utf8")) as IosVersionManifest;
|
|
}
|
|
|
|
export function writeIosVersionManifest(version: string, rootDir = path.resolve(".")): string {
|
|
const versionFilePath = path.join(rootDir, IOS_VERSION_FILE);
|
|
const normalizedVersion = normalizePinnedIosVersion(version);
|
|
const nextContent = `${JSON.stringify({ version: normalizedVersion }, null, 2)}\n`;
|
|
writeFileSync(versionFilePath, nextContent, "utf8");
|
|
return versionFilePath;
|
|
}
|
|
|
|
export function resolveIosVersion(rootDir = path.resolve(".")): ResolvedIosVersion {
|
|
const versionFilePath = path.join(rootDir, IOS_VERSION_FILE);
|
|
const changelogPath = path.join(rootDir, IOS_CHANGELOG_FILE);
|
|
const versionXcconfigPath = path.join(rootDir, IOS_VERSION_XCCONFIG_FILE);
|
|
const releaseNotesPath = path.join(rootDir, IOS_RELEASE_NOTES_FILE);
|
|
const manifest = readIosVersionManifest(rootDir);
|
|
const canonicalVersion = normalizePinnedIosVersion(manifest.version ?? "");
|
|
|
|
return {
|
|
canonicalVersion,
|
|
marketingVersion: canonicalVersion,
|
|
buildVersion: "1",
|
|
versionFilePath,
|
|
changelogPath,
|
|
versionXcconfigPath,
|
|
releaseNotesPath,
|
|
};
|
|
}
|
|
|
|
export function renderIosVersionXcconfig(version: ResolvedIosVersion): string {
|
|
return `// Shared iOS version defaults.\n// Source of truth: apps/ios/version.json\n// Generated by scripts/ios-sync-versioning.ts.\n\nOPENCLAW_IOS_VERSION = ${version.canonicalVersion}\nOPENCLAW_MARKETING_VERSION = ${version.marketingVersion}\nOPENCLAW_BUILD_VERSION = ${version.buildVersion}\n\n#include? "../build/Version.xcconfig"\n`;
|
|
}
|
|
|
|
function matchChangelogHeading(line: string, heading: string): boolean {
|
|
const normalized = line.trim();
|
|
return normalized === `## ${heading}` || normalized.startsWith(`## ${heading} - `);
|
|
}
|
|
|
|
export function extractChangelogSection(content: string, heading: string): string | null {
|
|
const lines = content.split(/\r?\n/);
|
|
const startIndex = lines.findIndex((line) => matchChangelogHeading(line, heading));
|
|
if (startIndex === -1) {
|
|
return null;
|
|
}
|
|
|
|
let endIndex = lines.length;
|
|
for (let index = startIndex + 1; index < lines.length; index += 1) {
|
|
if (lines[index]?.startsWith("## ")) {
|
|
endIndex = index;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const body = lines
|
|
.slice(startIndex + 1, endIndex)
|
|
.join("\n")
|
|
.trim();
|
|
return body || null;
|
|
}
|
|
|
|
export function renderIosReleaseNotes(
|
|
version: ResolvedIosVersion,
|
|
changelogContent: string,
|
|
): string {
|
|
const candidateHeadings = [version.canonicalVersion, "Unreleased"];
|
|
|
|
for (const heading of candidateHeadings) {
|
|
const body = extractChangelogSection(changelogContent, heading);
|
|
if (body) {
|
|
return `${body}\n`;
|
|
}
|
|
}
|
|
|
|
throw new Error(
|
|
`Unable to find iOS changelog notes for ${version.canonicalVersion}. Add a matching section to ${IOS_CHANGELOG_FILE}.`,
|
|
);
|
|
}
|
|
|
|
function syncFile(params: {
|
|
mode: SyncIosVersioningMode;
|
|
path: string;
|
|
nextContent: string;
|
|
label: string;
|
|
}): boolean {
|
|
const nextContent = normalizeTrailingNewline(params.nextContent);
|
|
const currentContent = readFileSync(params.path, "utf8");
|
|
if (currentContent === nextContent) {
|
|
return false;
|
|
}
|
|
|
|
if (params.mode === "check") {
|
|
throw new Error(`${params.label} is stale: ${path.relative(process.cwd(), params.path)}`);
|
|
}
|
|
|
|
writeFileSync(params.path, nextContent, "utf8");
|
|
return true;
|
|
}
|
|
|
|
export function syncIosVersioning(params?: { mode?: SyncIosVersioningMode; rootDir?: string }): {
|
|
updatedPaths: string[];
|
|
} {
|
|
const mode = params?.mode ?? "write";
|
|
const rootDir = path.resolve(params?.rootDir ?? ".");
|
|
const version = resolveIosVersion(rootDir);
|
|
const changelogContent = readFileSync(version.changelogPath, "utf8");
|
|
const nextVersionXcconfig = renderIosVersionXcconfig(version);
|
|
const nextReleaseNotes = renderIosReleaseNotes(version, changelogContent);
|
|
const updatedPaths: string[] = [];
|
|
|
|
if (
|
|
syncFile({
|
|
mode,
|
|
path: version.versionXcconfigPath,
|
|
nextContent: nextVersionXcconfig,
|
|
label: "iOS version xcconfig",
|
|
})
|
|
) {
|
|
updatedPaths.push(version.versionXcconfigPath);
|
|
}
|
|
|
|
if (
|
|
syncFile({
|
|
mode,
|
|
path: version.releaseNotesPath,
|
|
nextContent: nextReleaseNotes,
|
|
label: "iOS release notes",
|
|
})
|
|
) {
|
|
updatedPaths.push(version.releaseNotesPath);
|
|
}
|
|
|
|
return { updatedPaths };
|
|
}
|