feat(ios): pin calver release versioning (#63001)

* 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)
This commit is contained in:
Nimrod Gutman
2026-04-08 11:25:35 +03:00
committed by GitHub
parent 37e667c4c5
commit 6681878339
21 changed files with 1169 additions and 58 deletions

View File

@@ -8,7 +8,7 @@ Usage:
scripts/ios-beta-prepare.sh --build-number 7 [--team-id TEAMID]
Prepares local beta-release inputs without touching local signing overrides:
- reads package.json.version and writes apps/ios/build/Version.xcconfig
- reads apps/ios/version.json and writes apps/ios/build/Version.xcconfig
- writes apps/ios/build/BetaRelease.xcconfig with canonical bundle IDs
- configures the beta build for relay-backed APNs registration
- regenerates apps/ios/OpenClaw.xcodeproj via xcodegen
@@ -21,12 +21,14 @@ BUILD_DIR="${IOS_DIR}/build"
BETA_XCCONFIG="${IOS_DIR}/build/BetaRelease.xcconfig"
TEAM_HELPER="${ROOT_DIR}/scripts/ios-team-id.sh"
VERSION_HELPER="${ROOT_DIR}/scripts/ios-write-version-xcconfig.sh"
IOS_VERSION_HELPER="${ROOT_DIR}/scripts/ios-version.ts"
VERSION_SYNC_HELPER="${ROOT_DIR}/scripts/ios-sync-versioning.ts"
BUILD_NUMBER=""
TEAM_ID="${IOS_DEVELOPMENT_TEAM:-}"
PUSH_RELAY_BASE_URL="${OPENCLAW_PUSH_RELAY_BASE_URL:-${IOS_PUSH_RELAY_BASE_URL:-}}"
PUSH_RELAY_BASE_URL_XCCONFIG=""
PACKAGE_VERSION="$(cd "${ROOT_DIR}" && node -p "require('./package.json').version" 2>/dev/null || true)"
IOS_VERSION=""
prepare_build_dir() {
if [[ -L "${BUILD_DIR}" ]]; then
@@ -132,6 +134,16 @@ PUSH_RELAY_BASE_URL_XCCONFIG="$(
prepare_build_dir
(
cd "${ROOT_DIR}" && node --import tsx "${VERSION_SYNC_HELPER}" --check
)
IOS_VERSION="$(cd "${ROOT_DIR}" && node --import tsx "${IOS_VERSION_HELPER}" --field canonicalVersion)"
if [[ -z "${IOS_VERSION}" ]]; then
echo "Unable to resolve iOS version from ${ROOT_DIR}/apps/ios/version.json." >&2
exit 1
fi
(
bash "${VERSION_HELPER}" --build-number "${BUILD_NUMBER}"
)
@@ -161,5 +173,5 @@ EOF
xcodegen generate
)
echo "Prepared iOS beta release: version=${PACKAGE_VERSION} build=${BUILD_NUMBER} team=${TEAM_ID}"
echo "Prepared iOS beta release: version=${IOS_VERSION} build=${BUILD_NUMBER} team=${TEAM_ID}"
echo "XCODE_XCCONFIG_FILE=${BETA_XCCONFIG}"

148
scripts/ios-pin-version.ts Normal file
View File

@@ -0,0 +1,148 @@
import path from "node:path";
import {
normalizePinnedIosVersion,
resolveGatewayVersionForIosRelease,
resolveIosVersion,
syncIosVersioning,
writeIosVersionManifest,
} from "./lib/ios-version.ts";
type CliOptions = {
explicitVersion: string | null;
fromGateway: boolean;
rootDir: string;
sync: boolean;
};
export type PinIosVersionResult = {
previousVersion: string | null;
nextVersion: string;
packageVersion: string | null;
versionFilePath: string;
syncedPaths: string[];
};
function usage(): string {
return [
"Usage: node --import tsx scripts/ios-pin-version.ts (--from-gateway | --version <YYYY.M.D>) [--no-sync] [--root dir]",
"",
"Examples:",
" node --import tsx scripts/ios-pin-version.ts --from-gateway",
" node --import tsx scripts/ios-pin-version.ts --version 2026.4.10",
].join("\n");
}
export function parseArgs(argv: string[]): CliOptions {
let explicitVersion: string | null = null;
let fromGateway = false;
let rootDir = path.resolve(".");
let sync = true;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--from-gateway": {
fromGateway = true;
break;
}
case "--version": {
explicitVersion = argv[index + 1] ?? null;
index += 1;
break;
}
case "--no-sync": {
sync = false;
break;
}
case "--root": {
const value = argv[index + 1];
if (!value) {
throw new Error("Missing value for --root.");
}
rootDir = path.resolve(value);
index += 1;
break;
}
case "-h":
case "--help": {
console.log(`${usage()}\n`);
process.exit(0);
}
default: {
throw new Error(`Unknown argument: ${arg}`);
}
}
}
if (fromGateway === (explicitVersion !== null)) {
throw new Error("Choose exactly one of --from-gateway or --version <YYYY.M.D>.");
}
if (explicitVersion !== null && !explicitVersion.trim()) {
throw new Error("Missing value for --version.");
}
return { explicitVersion, fromGateway, rootDir, sync };
}
export function pinIosVersion(params: CliOptions): PinIosVersionResult {
const rootDir = path.resolve(params.rootDir);
let previousVersion: string | null = null;
try {
previousVersion = resolveIosVersion(rootDir).canonicalVersion;
} catch {
previousVersion = null;
}
const gatewayVersion = params.fromGateway ? resolveGatewayVersionForIosRelease(rootDir) : null;
const packageVersion = gatewayVersion?.packageVersion ?? null;
const nextVersion =
gatewayVersion?.pinnedIosVersion ?? normalizePinnedIosVersion(params.explicitVersion ?? "");
const versionFilePath = writeIosVersionManifest(nextVersion, rootDir);
const syncedPaths = params.sync ? syncIosVersioning({ mode: "write", rootDir }).updatedPaths : [];
return {
previousVersion,
nextVersion,
packageVersion,
versionFilePath,
syncedPaths,
};
}
export async function main(argv: string[]): Promise<number> {
try {
const options = parseArgs(argv);
const result = pinIosVersion(options);
const sourceText = result.packageVersion
? ` from gateway version ${result.packageVersion}`
: "";
process.stdout.write(`Pinned iOS version to ${result.nextVersion}${sourceText}.\n`);
if (result.previousVersion && result.previousVersion !== result.nextVersion) {
process.stdout.write(`Previous pinned iOS version: ${result.previousVersion}.\n`);
}
process.stdout.write(
`Updated version manifest: ${path.relative(process.cwd(), result.versionFilePath)}\n`,
);
if (options.sync) {
if (result.syncedPaths.length === 0) {
process.stdout.write("iOS versioning artifacts already up to date.\n");
} else {
process.stdout.write(
`Updated iOS versioning artifacts:\n- ${result.syncedPaths.map((filePath) => path.relative(process.cwd(), filePath)).join("\n- ")}\n`,
);
}
}
return 0;
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
const exitCode = await main(process.argv.slice(2));
if (exitCode !== 0) {
process.exit(exitCode);
}
}

View File

@@ -0,0 +1,57 @@
import path from "node:path";
import { syncIosVersioning } from "./lib/ios-version.ts";
type Mode = "check" | "write";
export function parseArgs(argv: string[]): { mode: Mode; rootDir: string } {
let mode: Mode = "write";
let rootDir = path.resolve(".");
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--check": {
mode = "check";
break;
}
case "--write": {
mode = "write";
break;
}
case "--root": {
const value = argv[index + 1];
if (!value) {
throw new Error("Missing value for --root.");
}
rootDir = path.resolve(value);
index += 1;
break;
}
case "-h":
case "--help": {
console.log(
"Usage: node --import tsx scripts/ios-sync-versioning.ts [--write|--check] [--root dir]",
);
process.exit(0);
}
default: {
throw new Error(`Unknown argument: ${arg}`);
}
}
}
return { mode, rootDir };
}
const options = parseArgs(process.argv.slice(2));
const result = syncIosVersioning({ mode: options.mode, rootDir: options.rootDir });
if (options.mode === "check") {
process.stdout.write("iOS versioning artifacts are up to date.\n");
} else if (result.updatedPaths.length === 0) {
process.stdout.write("iOS versioning artifacts already up to date.\n");
} else {
process.stdout.write(
`Updated iOS versioning artifacts:\n- ${result.updatedPaths.map((filePath) => path.relative(process.cwd(), filePath)).join("\n- ")}\n`,
);
}

78
scripts/ios-version.ts Normal file
View File

@@ -0,0 +1,78 @@
import path from "node:path";
import { resolveIosVersion } from "./lib/ios-version.ts";
type CliOptions = {
field: string | null;
format: "json" | "shell";
rootDir: string;
};
function parseArgs(argv: string[]): CliOptions {
let field: string | null = null;
let format: "json" | "shell" = "json";
let rootDir = path.resolve(".");
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--field": {
field = argv[index + 1] ?? null;
index += 1;
break;
}
case "--json": {
format = "json";
break;
}
case "--shell": {
format = "shell";
break;
}
case "--root": {
const value = argv[index + 1];
if (!value) {
throw new Error("Missing value for --root.");
}
rootDir = path.resolve(value);
index += 1;
break;
}
case "-h":
case "--help": {
console.log(
`Usage: node --import tsx scripts/ios-version.ts [--json|--shell] [--field name] [--root dir]\n`,
);
process.exit(0);
}
default: {
throw new Error(`Unknown argument: ${arg}`);
}
}
}
return { field, format, rootDir };
}
const options = parseArgs(process.argv.slice(2));
const version = resolveIosVersion(options.rootDir);
if (options.field) {
const value = version[options.field as keyof typeof version];
if (value === undefined) {
throw new Error(`Unknown iOS version field '${options.field}'.`);
}
process.stdout.write(`${String(value)}\n`);
process.exit(0);
}
if (options.format === "shell") {
process.stdout.write(
[
`OPENCLAW_IOS_VERSION=${version.canonicalVersion}`,
`OPENCLAW_MARKETING_VERSION=${version.marketingVersion}`,
`OPENCLAW_BUILD_VERSION=${version.buildVersion}`,
].join("\n") + "\n",
);
} else {
process.stdout.write(`${JSON.stringify(version, null, 2)}\n`);
}

View File

@@ -6,8 +6,8 @@ usage() {
Usage:
scripts/ios-write-version-xcconfig.sh [--build-number 7]
Writes apps/ios/build/Version.xcconfig from root package.json.version:
- OPENCLAW_GATEWAY_VERSION = exact package.json version
Writes apps/ios/build/Version.xcconfig from apps/ios/version.json:
- OPENCLAW_IOS_VERSION = exact canonical iOS version
- OPENCLAW_MARKETING_VERSION = short iOS/App Store version
- OPENCLAW_BUILD_VERSION = explicit build number or local numeric fallback
EOF
@@ -17,7 +17,9 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IOS_DIR="${ROOT_DIR}/apps/ios"
BUILD_DIR="${IOS_DIR}/build"
VERSION_XCCONFIG="${IOS_DIR}/build/Version.xcconfig"
PACKAGE_VERSION="$(cd "${ROOT_DIR}" && node -p "require('./package.json').version" 2>/dev/null || true)"
VERSION_HELPER="${ROOT_DIR}/scripts/ios-version.ts"
IOS_VERSION=""
MARKETING_VERSION=""
BUILD_NUMBER=""
prepare_build_dir() {
@@ -64,16 +66,19 @@ while [[ $# -gt 0 ]]; do
esac
done
PACKAGE_VERSION="$(printf '%s' "${PACKAGE_VERSION}" | tr -d '\n' | xargs)"
if [[ -z "${PACKAGE_VERSION}" ]]; then
echo "Unable to read package.json.version from ${ROOT_DIR}/package.json." >&2
exit 1
fi
while IFS='=' read -r key value; do
case "${key}" in
OPENCLAW_IOS_VERSION)
IOS_VERSION="${value}"
;;
OPENCLAW_MARKETING_VERSION)
MARKETING_VERSION="${value}"
;;
esac
done < <(cd "${ROOT_DIR}" && node --import tsx "${VERSION_HELPER}" --shell)
if [[ "${PACKAGE_VERSION}" =~ ^([0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2})([.-]?beta[.-][0-9]+)?$ ]]; then
MARKETING_VERSION="${BASH_REMATCH[1]}"
else
echo "Unsupported package.json.version '${PACKAGE_VERSION}'. Expected 2026.3.13 or 2026.3.13-beta.1." >&2
if [[ -z "${IOS_VERSION}" || -z "${MARKETING_VERSION}" ]]; then
echo "Unable to resolve iOS version metadata from ${ROOT_DIR}/apps/ios/version.json." >&2
exit 1
fi
@@ -91,9 +96,9 @@ prepare_build_dir
write_generated_file "${VERSION_XCCONFIG}" <<EOF
// Auto-generated by scripts/ios-write-version-xcconfig.sh.
// Local version override; do not commit.
OPENCLAW_GATEWAY_VERSION = ${PACKAGE_VERSION}
OPENCLAW_IOS_VERSION = ${IOS_VERSION}
OPENCLAW_MARKETING_VERSION = ${MARKETING_VERSION}
OPENCLAW_BUILD_VERSION = ${BUILD_NUMBER}
EOF
echo "Prepared iOS version settings: gateway=${PACKAGE_VERSION} marketing=${MARKETING_VERSION} build=${BUILD_NUMBER}"
echo "Prepared iOS version settings: ios=${IOS_VERSION} marketing=${MARKETING_VERSION} build=${BUILD_NUMBER}"

218
scripts/lib/ios-version.ts Normal file
View File

@@ -0,0 +1,218 @@
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 };
}