Files
openclaw/scripts/lib/ios-release-plan.ts
2026-07-23 19:08:40 -05:00

398 lines
14 KiB
TypeScript

// iOS release planning keeps App Store version and build selection deterministic.
import { readFileSync } from "node:fs";
import path from "node:path";
import {
encodeIosAppStoreVersion,
extractChangelogSection,
MAX_IOS_APP_STORE_REVISION,
normalizeIosAppStoreRevision,
normalizePinnedIosVersion,
} from "./ios-version.ts";
const IOS_BUILD_UPLOAD_STATES = ["AWAITING_UPLOAD", "PROCESSING", "FAILED", "COMPLETE"] as const;
// 2026.7.2 is the last exact-version iOS release in App Store Connect.
// Later exact CalVer values must not be confused with appended revision versions.
const LAST_LEGACY_IOS_APP_STORE_VERSION = "2026.7.2";
const EDITABLE_APP_STORE_VERSION_STATES = new Set([
"PREPARE_FOR_SUBMISSION",
"DEVELOPER_REJECTED",
"REJECTED",
"METADATA_REJECTED",
"INVALID_BINARY",
"READY_FOR_REVIEW",
]);
const RELEASED_APP_STORE_VERSION_STATES = new Set([
"READY_FOR_DISTRIBUTION",
"REPLACED_WITH_NEW_VERSION",
"READY_FOR_SALE",
"REMOVED_FROM_SALE",
"DEVELOPER_REMOVED_FROM_SALE",
]);
type IosRemoteAppStoreVersion = {
id: string;
state: string;
versionString: string;
};
type IosRemoteBuildUpload = {
buildNumber: string;
shortVersion: string;
state: string;
};
export type IosReleasePlanInput = {
appStoreVersions: IosRemoteAppStoreVersion[];
buildUploads: IosRemoteBuildUpload[];
explicitBuildNumber?: string | null;
explicitRevision?: string | number | null;
gatewayVersion: string;
rootDir?: string;
sourceClean?: boolean;
sourceSha?: string | null;
};
export type IosReleasePlan = {
appStoreRevision: number;
appStoreVersion: string;
appStoreVersionId: string | null;
appStoreVersionState: string | null;
buildNumber: number;
buildUploads: IosRemoteBuildUpload[];
changelogStatus: "needs-cut" | "ready";
decision: "new-revision" | "resume-editable" | "retry-upload";
gatewayVersion: string;
sourceClean: boolean | null;
sourceSha: string | null;
};
type DecodedVersion = {
legacy: boolean;
revision: number;
};
function parseVersionComponents(version: string): [number, number, number] | null {
const match = /^(\d{4})\.(\d{1,2})\.(\d+)$/u.exec(version.trim());
if (!match) {
return null;
}
const components = match.slice(1).map(Number);
if (components.some((value) => !Number.isSafeInteger(value))) {
return null;
}
return components as [number, number, number];
}
function compareAppStoreVersions(left: string, right: string): number {
const leftComponents = parseVersionComponents(left);
const rightComponents = parseVersionComponents(right);
if (!leftComponents || !rightComponents) {
throw new Error(`Unable to compare App Store versions '${left}' and '${right}'.`);
}
for (let index = 0; index < leftComponents.length; index += 1) {
const difference = (leftComponents[index] ?? 0) - (rightComponents[index] ?? 0);
if (difference !== 0) {
return difference;
}
}
return 0;
}
export function decodeIosAppStoreVersion(
gatewayVersion: string,
appStoreVersion: string,
): DecodedVersion | null {
const canonicalGatewayVersion = normalizePinnedIosVersion(gatewayVersion);
const gateway = parseVersionComponents(canonicalGatewayVersion);
const candidate = parseVersionComponents(appStoreVersion);
if (!gateway || !candidate || gateway[0] !== candidate[0] || gateway[1] !== candidate[1]) {
return null;
}
if (candidate[2] === gateway[2]) {
return compareAppStoreVersions(canonicalGatewayVersion, LAST_LEGACY_IOS_APP_STORE_VERSION) <= 0
? { legacy: true, revision: 0 }
: null;
}
const gatewayPatch = gateway[2].toString();
const candidatePatch = candidate[2].toString();
if (!candidatePatch.startsWith(gatewayPatch)) {
return null;
}
const revision = candidatePatch.slice(gatewayPatch.length);
if (!/^\d$/u.test(revision)) {
return null;
}
return { legacy: false, revision: Number(revision) };
}
function normalizeBuildNumber(rawBuildNumber: string): number {
const normalized = rawBuildNumber.trim();
if (!/^[1-9]\d*$/u.test(normalized)) {
throw new Error(
`Invalid App Store build number '${rawBuildNumber}'. Expected a positive integer.`,
);
}
const buildNumber = Number(normalized);
if (!Number.isSafeInteger(buildNumber)) {
throw new Error(`Invalid App Store build number '${rawBuildNumber}'. Expected a safe integer.`);
}
return buildNumber;
}
function relevantBuildUploads(
uploads: IosRemoteBuildUpload[],
shortVersion: string,
): IosRemoteBuildUpload[] {
return uploads.filter((upload) => {
if (upload.shortVersion !== shortVersion) {
return false;
}
if (!(IOS_BUILD_UPLOAD_STATES as readonly string[]).includes(upload.state)) {
throw new Error(
`Unknown App Store build upload state '${upload.state}' for ${upload.shortVersion} build ${upload.buildNumber}.`,
);
}
normalizeBuildNumber(upload.buildNumber);
return true;
});
}
function nextBuildNumber(uploads: IosRemoteBuildUpload[], shortVersion: string): number {
const builds = relevantBuildUploads(uploads, shortVersion).map((upload) =>
normalizeBuildNumber(upload.buildNumber),
);
return builds.length === 0 ? 1 : Math.max(...builds) + 1;
}
function assertExplicitSelection(
plan: Pick<IosReleasePlan, "appStoreRevision" | "buildNumber">,
input: IosReleasePlanInput,
): void {
if (input.explicitRevision !== null && input.explicitRevision !== undefined) {
const explicitRevision = normalizeIosAppStoreRevision(input.explicitRevision);
if (explicitRevision !== plan.appStoreRevision) {
throw new Error(
`Explicit App Store revision ${explicitRevision} does not match the deterministic revision ${plan.appStoreRevision}.`,
);
}
}
const explicitBuild = input.explicitBuildNumber?.trim() ?? "";
if (explicitBuild) {
const buildNumber = normalizeBuildNumber(explicitBuild);
if (buildNumber !== plan.buildNumber) {
throw new Error(
`Explicit App Store build ${buildNumber} does not match the deterministic next build ${plan.buildNumber}.`,
);
}
}
}
export function resolveIosReleasePlan(input: IosReleasePlanInput): IosReleasePlan {
const gatewayVersion = normalizePinnedIosVersion(input.gatewayVersion);
const decodedVersions = input.appStoreVersions.map((version) => ({
decoded: decodeIosAppStoreVersion(gatewayVersion, version.versionString),
version,
}));
// App Store Connect permits only one mutable iOS version. Treat any extra
// active record as ambiguous instead of guessing which release owns it.
const activeVersions = input.appStoreVersions.filter(
(version) => !RELEASED_APP_STORE_VERSION_STATES.has(version.state),
);
if (activeVersions.length > 1) {
throw new Error(
`App Store Connect has multiple active iOS versions: ${activeVersions
.map((version) => `${version.versionString} (${version.state})`)
.join(", ")}.`,
);
}
let revision: number;
let decision: IosReleasePlan["decision"];
let selectedVersion: IosRemoteAppStoreVersion | null = null;
if (activeVersions.length === 1) {
selectedVersion = activeVersions[0] ?? null;
if (!selectedVersion || !EDITABLE_APP_STORE_VERSION_STATES.has(selectedVersion.state)) {
throw new Error(
`App Store version ${selectedVersion?.versionString ?? "unknown"} is locked in state ${selectedVersion?.state ?? "UNKNOWN"}.`,
);
}
const decoded = decodeIosAppStoreVersion(gatewayVersion, selectedVersion.versionString);
if (!decoded || decoded.legacy) {
throw new Error(
`Editable App Store version ${selectedVersion.versionString} does not belong to gateway ${gatewayVersion}.`,
);
}
revision = decoded.revision;
decision = "resume-editable";
} else {
const releasedRevisions = decodedVersions.flatMap(({ decoded, version }) =>
decoded && RELEASED_APP_STORE_VERSION_STATES.has(version.state) ? [decoded.revision] : [],
);
let hasLegacyUpload = false;
const uploadedRevisions = input.buildUploads.flatMap((upload) => {
const decoded = decodeIosAppStoreVersion(gatewayVersion, upload.shortVersion);
if (!decoded) {
return [];
}
if (!(IOS_BUILD_UPLOAD_STATES as readonly string[]).includes(upload.state)) {
throw new Error(
`Unknown App Store build upload state '${upload.state}' for ${upload.shortVersion} build ${upload.buildNumber}.`,
);
}
normalizeBuildNumber(upload.buildNumber);
if (decoded.legacy) {
hasLegacyUpload = true;
return [];
}
return [decoded.revision];
});
const highestReleased = releasedRevisions.length === 0 ? -1 : Math.max(...releasedRevisions);
const highestUploaded = uploadedRevisions.length === 0 ? -1 : Math.max(...uploadedRevisions);
const unreleasedUploadedRevisions = [
...new Set(uploadedRevisions.filter((uploaded) => uploaded > highestReleased)),
];
if (unreleasedUploadedRevisions.length > 1) {
throw new Error(
`Multiple unreleased App Store build-upload revisions exist for gateway ${gatewayVersion}: ${unreleasedUploadedRevisions.toSorted((left, right) => left - right).join(", ")}. Resolve App Store Connect state before retrying.`,
);
}
// Build-upload history survives processing failures and a manually removed
// version record, so retry that public revision until it is distributed.
if (highestUploaded > highestReleased) {
revision = highestUploaded;
decision = "retry-upload";
} else {
const historicalRevisions = decodedVersions.flatMap(({ decoded }) =>
decoded ? [decoded.revision] : [],
);
if (hasLegacyUpload) {
historicalRevisions.push(0);
}
const highestHistorical =
historicalRevisions.length === 0 ? -1 : Math.max(...historicalRevisions);
revision = Math.max(highestHistorical, highestUploaded) + 1;
decision = "new-revision";
}
}
if (revision > MAX_IOS_APP_STORE_REVISION) {
throw new Error(
`Gateway ${gatewayVersion} has exhausted App Store revisions 0 through ${MAX_IOS_APP_STORE_REVISION}.`,
);
}
const appStoreVersion = encodeIosAppStoreVersion(gatewayVersion, revision);
const releasedVersions = input.appStoreVersions
.filter((version) => RELEASED_APP_STORE_VERSION_STATES.has(version.state))
.map((version) => version.versionString)
.toSorted(compareAppStoreVersions);
const latestReleasedVersion = releasedVersions.at(-1);
if (
latestReleasedVersion &&
compareAppStoreVersions(appStoreVersion, latestReleasedVersion) <= 0
) {
throw new Error(
`Planned App Store version ${appStoreVersion} must be greater than latest released version ${latestReleasedVersion}.`,
);
}
const uploads = relevantBuildUploads(input.buildUploads, appStoreVersion);
const buildNumber = nextBuildNumber(input.buildUploads, appStoreVersion);
const rootDir = path.resolve(input.rootDir ?? ".");
const changelog = readFileSync(path.join(rootDir, "apps/ios/CHANGELOG.md"), "utf8");
const hasReleaseNotes = Boolean(extractChangelogSection(changelog, appStoreVersion));
const hasUnreleasedNotes = Boolean(extractChangelogSection(changelog, "Unreleased"));
const changelogStatus = hasReleaseNotes && !hasUnreleasedNotes ? "ready" : "needs-cut";
const plan: IosReleasePlan = {
appStoreRevision: revision,
appStoreVersion,
appStoreVersionId: selectedVersion?.id ?? null,
appStoreVersionState: selectedVersion?.state ?? null,
buildNumber,
buildUploads: uploads,
changelogStatus,
decision,
gatewayVersion,
sourceClean: input.sourceClean ?? null,
sourceSha: input.sourceSha?.trim() || null,
};
assertExplicitSelection(plan, input);
return plan;
}
type ChangelogSection = {
body: string;
end: number;
heading: string;
headingLine: string;
start: number;
};
function changelogSections(content: string): ChangelogSection[] {
const lines = content.split(/\r?\n/u);
const starts = lines.flatMap((line, index) => (line.startsWith("## ") ? [index] : []));
return starts.map((start, index) => {
const end = starts[index + 1] ?? lines.length;
return {
body: lines
.slice(start + 1, end)
.join("\n")
.trim(),
end,
heading: lines[start]?.slice(3).split(" - ", 1)[0]?.trim() ?? "",
headingLine: lines[start] ?? "",
start,
};
});
}
export function cutIosReleaseChangelog(content: string, appStoreVersion: string): string {
const lines = content.split(/\r?\n/u);
const sections = changelogSections(content);
const unreleased = sections.find((section) => section.heading === "Unreleased");
if (!unreleased) {
throw new Error("Missing ## Unreleased section in apps/ios/CHANGELOG.md.");
}
const target = sections.find((section) => section.heading === appStoreVersion);
if (!unreleased.body && !target?.body) {
throw new Error(`No release notes are available for App Store version ${appStoreVersion}.`);
}
if (!unreleased.body) {
return content;
}
const targetBody = [unreleased.body, target?.body].filter(Boolean).join("\n\n");
// Retry fixes join the same public release notes. Clearing Unreleased makes
// the cut idempotent and keeps the committed heading as upload provenance.
const beforeUnreleased = lines.slice(0, unreleased.start);
const afterUnreleased = lines.slice(unreleased.end);
let nextLines = [...beforeUnreleased, "## Unreleased", ""];
if (target) {
const adjustedTarget = changelogSections(afterUnreleased.join("\n")).find(
(section) => section.heading === appStoreVersion,
);
if (!adjustedTarget) {
throw new Error(`Unable to locate App Store changelog section ${appStoreVersion}.`);
}
nextLines = [
...nextLines,
...afterUnreleased.slice(0, adjustedTarget.start),
adjustedTarget.headingLine,
"",
targetBody,
"",
...afterUnreleased.slice(adjustedTarget.end),
];
} else {
nextLines = [...nextLines, `## ${appStoreVersion}`, "", targetBody, "", ...afterUnreleased];
}
return `${nextLines
.join("\n")
.replace(/\n{3,}/gu, "\n\n")
.trimEnd()}\n`;
}