- apple testflight wait --build N (progress, timeout, exit 0/1/7) and expire --keep N | --older-than D (dry run, asks) - apple iap-submit: IAP / subscription / group versions into a review submission; first of a type only with the app version (--with-version), --no-submit keeps the draft; read-only MCP plan - apple listing pull, play listing pull (diff, --write), catalog pull (config proposal, never rewritten) - whats-new --languages / check / apply: one file per language, agent or hook translation, per-store limits - apple offer-codes, win-back, promoted, preorder; Play pre-registration as a guide (no API) - apple cpp and apple events: list, create, upload, submit (asks) - reports: Apple sales / finance, Play earnings / sales into .storebound/reports with summary and CSV - triage: TestFlight crash / feedback and Play error reports grouped by signature, device, OS - aso rank: iTunes Search API ranks and competitors, rate limited - ads scan: AdAttributionKit / SKAdNetwork Info.plist and source checks - tests against recorded, sanitized answers; skills, README, CHANGELOG
89 lines
5.9 KiB
TypeScript
89 lines
5.9 KiB
TypeScript
// 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 <name>, 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 <n> or --older-than <days> (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<string, unknown>; relationships?: Record<string, { data?: { id: string } | null }> }[]; 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;
|
|
}
|