- validate: read-only App Store Connect + Play report with ordered blockers (exit 1) - apple release --phased on|pause|resume|complete and --manual-release (asks first) - apple status --wait-review (exit 0 approved, 1 rejected, 7 timeout), apple next-build, apple encryption - play rollout --fraction|--halt|--resume|--complete, play next-version-code - check --aso, whats-new --since <tag>, privacy-manifest --ios <path>, version - Apple IAP description limit is 45 characters (App Store Connect Help), not 55 - config: apple.usesNonExemptEncryption, apple.screenshotTypes, ads.appAdsTxt
114 lines
6.8 KiB
TypeScript
114 lines
6.8 KiB
TypeScript
// 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] };
|
|
}
|