openclaw/scripts/lib/ios-release-plan.ts
Josh Avant 8c6fbf5f89
feat(ios): distribute daily TestFlight builds for external testing (#161460)
* feat(ios): distribute daily TestFlight builds for external testing

* fix(ios): reuse App Store builds for first TestFlight distribution

Recognize the App Store-selected build of the same recorded source when it is eligible for beta submission and has no beta notes. Freeze its identity and notes and stage it without another archive or upload. Verify the adopted build ID and preserve existing beta notes on recovery.

The CLI and registered Fastlane regression cases fail before the correction. The complete two-file suite passes 43 cases; the noted-store recovery guard also passes. Script and affected root-test types, scoped lint, formatting, and independent P0-P2 review pass.
2026-09-29 20:43:46 -05:00

434 lines
15 KiB
TypeScript

// iOS release planning keeps App Store version and build selection deterministic.
import {
encodeIosAppStoreVersion,
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;
};
type IosReleaseDestination = "app-store" | "testflight";
export type IosReleasePlanInput = {
appStoreVersions: IosRemoteAppStoreVersion[];
buildUploads: IosRemoteBuildUpload[];
destination?: IosReleaseDestination;
explicitBuildNumber?: string | null;
explicitRevision?: string | number | null;
gatewayVersion: string;
releaseNotesBaselines: [{ audience: "ios"; version: string | null; build: string | null }];
sourceClean?: boolean;
sourceSha?: string | null;
};
export type IosReleasePlan = {
appStoreRevision: number;
appStoreVersion: string;
appStoreVersionId: string | null;
appStoreVersionState: string | null;
buildNumber: number;
buildUploads: IosRemoteBuildUpload[];
releaseNotesBaselines: IosReleasePlanInput["releaseNotesBaselines"];
decision:
| "new-revision"
| "resume-editable"
| "resume-testflight"
| "retry-upload"
| "stage-existing";
destination: IosReleaseDestination;
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 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 destination = input.destination ?? "app-store";
if (destination !== "app-store" && destination !== "testflight") {
throw new Error("Unknown iOS release destination. Choose app-store or testflight.");
}
const gatewayVersion = normalizePinnedIosVersion(input.gatewayVersion);
const decodedVersions = input.appStoreVersions.map((version) => ({
decoded: decodeIosAppStoreVersion(gatewayVersion, version.versionString),
version,
}));
// Store releases own the mutable version. TestFlight shares its revision when
// it matches this gateway, but does not depend on another train's store draft.
const activeVersions = decodedVersions
.filter(
({ decoded, version }) =>
!RELEASED_APP_STORE_VERSION_STATES.has(version.state) &&
(destination === "app-store" || (decoded && !decoded.legacy)),
)
.map(({ version }) => version);
if (activeVersions.length > 1) {
throw new Error(
`App Store Connect has multiple active iOS versions: ${activeVersions
.map((version) => `${version.versionString} (${version.state})`)
.join(", ")}.`,
);
}
const releasedRevisions = decodedVersions.flatMap(({ decoded, version }) =>
decoded && RELEASED_APP_STORE_VERSION_STATES.has(version.state) ? [decoded.revision] : [],
);
const highestReleased = releasedRevisions.length === 0 ? -1 : Math.max(...releasedRevisions);
let revision: number;
let decision: IosReleasePlan["decision"];
let selectedVersion: IosRemoteAppStoreVersion | null = null;
if (activeVersions.length === 1) {
selectedVersion = activeVersions[0] ?? null;
if (
!selectedVersion ||
(destination === "app-store" && !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 = destination === "testflight" ? "resume-testflight" : "resume-editable";
if (destination === "testflight") {
const conflictingUploads = input.buildUploads.filter((upload) => {
const uploaded = decodeIosAppStoreVersion(gatewayVersion, upload.shortVersion);
return (
uploaded &&
!uploaded.legacy &&
uploaded.revision > highestReleased &&
uploaded.revision !== revision
);
});
if (conflictingUploads.length > 0) {
throw new Error(
`Multiple unreleased TestFlight revisions exist for gateway ${gatewayVersion}: App Store version ${selectedVersion.versionString} conflicts with uploaded ${[...new Set(conflictingUploads.map((upload) => upload.shortVersion))].join(", ")}. Resolve App Store Connect state before retrying.`,
);
}
}
} else {
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 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 = Math.max(0, ...uploads.map((upload) => Number(upload.buildNumber))) + 1;
const baselines = input.releaseNotesBaselines;
const baseline = baselines?.[0];
if (
baselines?.length !== 1 ||
baseline?.audience !== "ios" ||
baseline.version !== (latestReleasedVersion ?? null) ||
(latestReleasedVersion ? typeof baseline.build !== "string" : baseline.build !== null)
) {
throw new Error(
"Missing or inconsistent latest public App Store version/build notes baseline.",
);
}
if (baseline.build !== null) {
normalizeBuildNumber(baseline.build);
}
const plan: IosReleasePlan = {
appStoreRevision: revision,
appStoreVersion,
appStoreVersionId: destination === "app-store" ? (selectedVersion?.id ?? null) : null,
appStoreVersionState: destination === "app-store" ? (selectedVersion?.state ?? null) : null,
buildNumber,
buildUploads: uploads,
releaseNotesBaselines: baselines,
decision,
destination,
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`;
}