mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 17:51:38 +00:00
398 lines
14 KiB
TypeScript
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`;
|
|
}
|