storebound/cli/lib/release.ts
CRT 1fc94da443 CLI: store readiness, release management, export compliance, ASO audit, release notes, privacy manifest scan
- 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
2026-10-10 21:02:55 +08:00

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] };
}