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 }; }