// TestFlight build housekeeping as pure parts: waiting for an uploaded build to finish processing, and expiring old // builds. The command (cli/apple/testflight.ts wait | expire) reads, calls these and prints / sends. // API (App Store Connect API 4.5.1 OpenAPI spec and docs, checked October 2026): // GET /v1/builds filter[app], filter[version], filter[expired], filter[preReleaseVersion.platform], sort=-uploadedDate, // include=preReleaseVersion,buildBetaDetail,appStoreVersion // https://developer.apple.com/documentation/appstoreconnectapi/get-v1-builds // Build.processingState: PROCESSING | FAILED | INVALID | VALID // PATCH /v1/builds/{id} { expired: true } "Expire a build or change its encryption exemption setting." // https://developer.apple.com/documentation/appstoreconnectapi/patch-v1-builds-_id_ // Expiring is permanent: testers lose the build, it cannot be un-expired. So `expire` is a dry run first, then a // terminal confirmation; it is not an MCP tool. import type { Req } from '../apple/jsonapi'; export type WaitOutcome = 'missing' | 'processing' | 'valid' | 'failed'; export interface BuildSeen { found: boolean; processingState?: string; internal?: string | null; external?: string | null } /** One poll result -> what it means for the waiter. A build that is not listed yet is normal for some minutes after * the upload (App Store Connect lists it once processing starts). */ export function waitOutcome(b: BuildSeen): WaitOutcome { if (!b.found) return 'missing'; if (b.processingState === 'VALID') return 'valid'; if (b.processingState === 'FAILED' || b.processingState === 'INVALID') return 'failed'; return 'processing'; } /** Poll delay: 20 s at first, x1.5 per poll, at most 2 minutes (processing usually takes 5 to 30 minutes). */ export function waitDelay(prevMs: number | null): number { return prevMs === null ? 20_000 : Math.min(Math.round(prevMs * 1.5), 120_000); } /** Exit codes of `testflight wait`: 0 VALID, 1 FAILED / INVALID, 7 timeout (the same as `apple status --wait-review`). */ export const BUILD_WAIT_EXIT = { valid: 0, failed: 1, timeout: 7 } as const; /** What the build state means for the next step, one line. */ export function validHint(b: BuildSeen): string { if (b.internal === 'MISSING_EXPORT_COMPLIANCE' || b.external === 'MISSING_EXPORT_COMPLIANCE') return 'export compliance missing: storebound apple encryption --build N (from apple.usesNonExemptEncryption)'; return 'ready: storebound apple testflight distribute --build N --group , or apple listing --build N'; } export interface ExpireCandidate { id: string; build: string; version?: string; uploaded: string; processingState?: string; /** Attached to an App Store version (in review, live or being prepared): never expired by storebound. */ attached: boolean; } export interface ExpirePlan { expire: ExpireCandidate[]; keep: ExpireCandidate[]; protected: { b: ExpireCandidate; why: string }[]; reqs: Req[] } /** * Which builds to expire. `builds` = the app's builds that are not expired yet (any order). keep = keep the newest N * builds (by upload date; protected builds count as kept), olderThanDays = only builds uploaded before now - D days. * With both, a build must be beyond the newest N AND older than D days. Builds attached to an App Store version and * builds still processing are never expired. Pure. */ export function planExpire(builds: ExpireCandidate[], o: { keep?: number; olderThanDays?: number; now?: number }): ExpirePlan { if (o.keep === undefined && o.olderThanDays === undefined) throw new Error('--keep or --older-than (or both)'); if (o.keep !== undefined && !(Number.isInteger(o.keep) && o.keep >= 1)) throw new Error('--keep: a whole number >= 1 (the newest build always stays)'); if (o.olderThanDays !== undefined && !(o.olderThanDays >= 1)) throw new Error('--older-than: days >= 1'); const now = o.now ?? Date.now(); const sorted = [...builds].sort((a, b) => b.uploaded.localeCompare(a.uploaded)); const plan: ExpirePlan = { expire: [], keep: [], protected: [], reqs: [] }; sorted.forEach((b, i) => { if (b.attached) { plan.protected.push({ b, why: 'attached to an App Store version' }); return; } if (b.processingState && b.processingState !== 'VALID' && b.processingState !== 'FAILED' && b.processingState !== 'INVALID') { plan.protected.push({ b, why: `still ${b.processingState}` }); return; } if (i === 0) { plan.keep.push(b); return; } const beyondKeep = o.keep === undefined || i >= o.keep; const old = o.olderThanDays === undefined || now - Date.parse(b.uploaded) > o.olderThanDays * 86_400_000; (beyondKeep && old ? plan.expire : plan.keep).push(b); }); plan.reqs = plan.expire.map(b => ({ method: 'PATCH', path: `/v1/builds/${b.id}`, note: `expire build ${b.build}${b.version ? ` (${b.version})` : ''}, uploaded ${b.uploaded.slice(0, 10)}`, body: { data: { type: 'builds', id: b.id, attributes: { expired: true } } } })); return plan; } /** Builds response (include=preReleaseVersion,appStoreVersion) -> expire candidates. Pure. */ export function expireCandidates(pages: { data?: { id: string; attributes?: Record; relationships?: Record }[]; included?: { type: string; id: string; attributes?: { version?: string } }[] }[]): ExpireCandidate[] { const out: ExpireCandidate[] = []; for (const page of pages) { const pre = new Map((page.included ?? []).filter(x => x.type === 'preReleaseVersions').map(x => [x.id, x.attributes?.version] as const)); for (const b of page.data ?? []) { const a = b.attributes ?? {}; if (a.expired === true) continue; out.push({ id: b.id, build: String(a.version ?? '?'), version: pre.get(b.relationships?.preReleaseVersion?.data?.id ?? ''), uploaded: String(a.uploadedDate ?? ''), processingState: a.processingState as string | undefined, attached: !!b.relationships?.appStoreVersion?.data }); } } return out; }