// Pure helpers for release management on both stores: review state classification, polling backoff, next build // numbers, Play staged rollout transitions and Apple phased release transitions. export type ReviewOutcome = 'pending' | 'approved' | 'rejected' | 'not-submitted'; /** App Store version state -> what `apple status --wait-review` should do. */ export function classifyReviewState(state: string): ReviewOutcome { if (['WAITING_FOR_REVIEW', 'IN_REVIEW', 'PENDING_APPLE_RELEASE', 'WAITING_FOR_EXPORT_COMPLIANCE', 'PROCESSING_FOR_APP_STORE', 'UNRESOLVED_ISSUES'].includes(state)) return 'pending'; if (['PENDING_DEVELOPER_RELEASE', 'PROCESSING_FOR_DISTRIBUTION', 'READY_FOR_DISTRIBUTION', 'READY_FOR_SALE', 'ACCEPTED', 'PREORDER_READY_FOR_SALE'].includes(state)) return 'approved'; if (['REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY', 'DEVELOPER_REJECTED'].includes(state)) return 'rejected'; return 'not-submitted'; } /** Exit codes of `apple status --wait-review`. */ export const WAIT_EXIT = { approved: 0, rejected: 1, timeout: 7 } as const; /** Polling delay: starts at 60 s, x1.5 per round, capped at 15 min. */ export function nextPollDelay(prevMs: number | null, o: { first?: number; factor?: number; max?: number } = {}): number { const first = o.first ?? 60_000; const max = o.max ?? 15 * 60_000; if (prevMs === null) return first; return Math.min(max, Math.round(prevMs * (o.factor ?? 1.5))); } /** "48h", "90m", "30s", "2d" or plain seconds -> ms. */ export function parseDuration(s: string): number { const m = /^(\d+(?:\.\d+)?)(s|m|h|d)?$/.exec(s.trim()); if (!m) throw new Error(`bad duration "${s}" (use 30m, 12h, 2d)`); const mult = { s: 1e3, m: 6e4, h: 36e5, d: 864e5 }[(m[2] ?? 's') as 's' | 'm' | 'h' | 'd']; return Math.round(Number(m[1]) * mult); } /** Compares build numbers like "12", "1.0.12" by numeric parts. */ export function compareBuild(a: string, b: string): number { const pa = a.split('.').map(Number); const pb = b.split('.').map(Number); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pa[i] ?? 0) - (pb[i] ?? 0); if (d) return d; } return 0; } /** Next safe CFBundleVersion: one above the highest build ever uploaded for the app (any version train). */ export function nextBuildNumber(existing: string[]): string { const valid = existing.filter(b => /^\d+(\.\d+){0,2}$/.test(b)); if (!valid.length) return '1'; const top = valid.sort(compareBuild).at(-1)!; const parts = top.split('.'); parts[parts.length - 1] = String(Number(parts.at(-1)) + 1); return parts.join('.'); } /** Next Play versionCode: one above every code seen in bundles and tracks. Play caps it at 2100000000. */ export function nextVersionCode(codes: (string | number)[]): number { const max = codes.map(Number).filter(n => Number.isInteger(n) && n > 0).reduce((a, b) => Math.max(a, b), 0); if (max + 1 > 2_100_000_000) throw new Error('versionCode would exceed 2100000000'); return max + 1; } export interface PlayRelease { name?: string; versionCodes?: string[]; status: string; userFraction?: number; releaseNotes?: unknown[]; [k: string]: unknown } export type RolloutAction = { kind: 'fraction'; value: number } | { kind: 'halt' } | { kind: 'resume'; value?: number } | { kind: 'complete' }; /** * New releases array for a track after a staged-rollout action. Works on the release that is being rolled out * (inProgress or halted). Completing keeps only that release (Play drops the older completed one itself). */ export function rolloutReleases(releases: PlayRelease[], a: RolloutAction): { releases: PlayRelease[]; summary: string } { const staged = releases.find(r => r.status === 'inProgress' || r.status === 'halted'); if (!staged) throw new Error(`no staged rollout on this track (releases: ${releases.map(r => r.status).join(', ') || 'none'}); start one with play release --production inProgress --fraction 0.05`); const codes = (staged.versionCodes ?? []).join(','); const others = releases.filter(r => r !== staged); const now = staged.userFraction ?? 0; const pct = (x: number) => `${Math.round(x * 1000) / 10}%`; switch (a.kind) { case 'fraction': { if (!(a.value > 0 && a.value < 1)) throw new Error('--fraction must be between 0 and 1 (exclusive); use --complete for 100%'); if (staged.status === 'halted') throw new Error('rollout is halted: use --resume [--fraction x]'); if (a.value < now) throw new Error(`rollout is at ${pct(now)}; Play does not lower a fraction (halt instead)`); return { releases: [...others, { ...staged, userFraction: a.value }], summary: `${codes}: ${pct(now)} -> ${pct(a.value)}` }; } case 'halt': if (staged.status === 'halted') return { releases, summary: `${codes}: already halted` }; return { releases: [...others, { ...staged, status: 'halted' }], summary: `${codes}: halted at ${pct(now)} (users who updated keep it)` }; case 'resume': { if (staged.status !== 'halted') throw new Error('rollout is not halted'); const v = a.value ?? now; if (!(v > 0 && v < 1)) throw new Error('resume needs a fraction between 0 and 1'); return { releases: [...others, { ...staged, status: 'inProgress', userFraction: v }], summary: `${codes}: resumed at ${pct(v)}` }; } case 'complete': { const { userFraction: _drop, ...rest } = staged; return { releases: [{ ...rest, status: 'completed' }], summary: `${codes}: completed (100%)` }; } } } export type PhasedAction = 'on' | 'pause' | 'resume' | 'complete'; /** Apple phased release: request for an action given the current phased release (or none). */ export function phasedRequest(current: { id: string; state: string } | null, action: PhasedAction): { method: 'POST' | 'PATCH' | 'NONE'; state?: string; note: string } { if (action === 'on') { if (current) return { method: 'NONE', note: `phased release already exists (${current.state})` }; return { method: 'POST', note: 'phased release on: starts when the version is released (7 days, 1% -> 100%)' }; } if (!current) throw new Error('no phased release on this version (storebound apple release --phased on first)'); const want = { pause: 'PAUSED', resume: 'ACTIVE', complete: 'COMPLETE' }[action]; if (current.state === want) return { method: 'NONE', note: `already ${want}` }; if (current.state === 'COMPLETE') throw new Error('phased release is COMPLETE; nothing to change'); if (action === 'pause' && current.state !== 'ACTIVE') throw new Error(`can only pause an ACTIVE phased release (it is ${current.state})`); if (action === 'resume' && current.state !== 'PAUSED') throw new Error(`can only resume a PAUSED phased release (it is ${current.state})`); return { method: 'PATCH', state: want, note: { pause: 'paused (max 30 days in total; users can still update manually)', resume: 'resumed', complete: 'released to everyone' }[action] }; }