// Read-only overview: app record, versions + attached build, recent builds, in-app purchases, review submissions. // storebound apple status [--json] // And the one write it offers: cancel a review submission (to re-add first IAPs to it from the web UI): // storebound apple status --cancel-submission [--yes] // An empty draft (READY_FOR_REVIEW, 0 items) cannot be canceled (409 "Resource is not in cancellable state"); it is // harmless, shown as such and left as is. // A submission back from App Review (UNRESOLVED_ISSUES) is shown with its items; resubmit it with // storebound apple resubmit --build // Wait for App Review (polls with backoff: 1 min, x1.5, max 15 min): // storebound apple status --wait-review [--version 1.2.0] [--timeout 48h] // exit codes: 0 approved, 1 rejected, 7 timeout (2 = nothing in review) // Also compares the App ID's capabilities, apple.signing.capabilities and the iOS project's entitlements and warns, // naming what is missing where. import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { log, printJson, warn } from '../lib/log'; import type { FirstProductGap } from '../lib/review-submit'; import { firstProductReport } from './first-products'; import { classifyReviewState, nextPollDelay, parseDuration, WAIT_EXIT } from '../lib/release'; import { EMPTY_DRAFT } from '../lib/review-submit'; import { type Asc, ascClient, type J } from './asc'; import { notCancellable, readItems, type SubItem } from './submission'; import { capabilityReport, type CapabilityReport, printCapabilityReport } from './signing'; import * as rt from '../lib/rt'; async function waitReview(ctx: Ctx, c: Asc, appId: string): Promise { const want = ctx.args.get('version'); const deadline = Date.now() + parseDuration(ctx.args.str('timeout', '48h')); const state = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState; let delay: number | null = null; let last = ''; for (;;) { const vs = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=10`); const v = want ? vs.find(x => x.attributes.versionString === want) : vs.find(x => classifyReviewState(state(x)) === 'pending') ?? vs[0]; if (!v) { log('apple', `no version ${want ?? ''} found`); process.exit(2); } const st = state(v); const outcome = classifyReviewState(st); if (st !== last) { log('apple', `${v.attributes.versionString}: ${st}`); last = st; } if (outcome === 'approved') { log('apple', 'approved (release it yourself, or it goes out automatically if the release type says so)'); process.exit(WAIT_EXIT.approved); } if (outcome === 'rejected') { log('apple', 'rejected: read the message in App Store Connect > App Review, then the review-rejections skill'); process.exit(WAIT_EXIT.rejected); } if (outcome === 'not-submitted') { log('apple', 'nothing is waiting for review'); process.exit(2); } delay = nextPollDelay(delay); if (Date.now() + delay > deadline) { log('apple', 'timeout, still in review'); process.exit(WAIT_EXIT.timeout); } log('apple', `next check in ${Math.round(delay / 1000)}s`); await rt.sleep(delay); } } export async function run(ctx: Ctx): Promise { const { config, root } = ctx.loaded; const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId); const appId = await c.app(); if (ctx.args.has('wait-review')) await waitReview(ctx, c, appId); const cancel = ctx.args.get('cancel-submission'); if (cancel) { if (!(await confirm(ctx, `Cancel review submission ${cancel}? (the version goes back to "Prepare for Submission")`))) return; try { await c.asc('PATCH', `/v1/reviewSubmissions/${cancel}`, { data: { type: 'reviewSubmissions', id: cancel, attributes: { canceled: true } } }); } catch (e) { if (!notCancellable(e)) throw e; log('apple', `review submission ${cancel} is not cancellable (App Store Connect answered "Resource is not in cancellable state"); left as is. ` + `An ${EMPTY_DRAFT} does no harm: a later run reuses it`); return; } log('apple', `review submission ${cancel} canceled; it takes a minute to settle`); return; } const app = (await c.asc('GET', `/v1/apps/${appId}`)).data; const versions = await c.all(`/v1/apps/${appId}/appStoreVersions?limit=10&include=build`); const builds = (await c.asc('GET', `/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=5&include=preReleaseVersion`)); const iaps = await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`); const subs = await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&limit=20`); let caps: CapabilityReport | { error: string }; try { caps = await capabilityReport(c, config, root); } catch (e) { caps = { error: (e as Error).message.slice(0, 300) }; } // a submission back from App Review: its items and their states, and how to resubmit it const unresolved: { id: string; items: SubItem[] }[] = []; for (const s of subs.filter((x: J) => x.attributes?.state === 'UNRESOLVED_ISSUES')) unresolved.push({ id: s.id, items: await readItems(c, s.id) }); // an empty draft: App Store Connect keeps it and refuses to cancel it; harmless const empty = new Set(); for (const s of subs.filter((x: J) => x.attributes?.state === 'READY_FOR_REVIEW')) if (!(await readItems(c, s.id)).length) empty.add(s.id); // first products of a type that are not in the review (pitfall 70): not sellable after the version is approved const vstate = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState; const reviewed = versions.find((v: J) => /WAITING_FOR_REVIEW|IN_REVIEW/.test(vstate(v))) ?? versions[0]; let gaps: FirstProductGap[] = []; try { gaps = await firstProductReport(c, appId, { iaps, versionState: reviewed ? vstate(reviewed) : undefined }); } catch (e) { log('apple', `first in-app purchases not compared with the review: ${(e as Error).message.slice(0, 200)}`); } const itemLabel = (kind: string, id: string): string => { const v = versions.find((x: J) => x.id === id); return v ? `app version ${v.attributes.versionString}` : `${kind} ${id}`; }; if (ctx.args.has('json')) { // phased release of the newest versions that are live or close to it (rollout share on the desktop timeline) const phased: Record = {}; for (const v of versions.slice(0, 3)) { const st = String(v.attributes.appVersionState ?? v.attributes.appStoreState ?? ''); if (!/READY_FOR_SALE|READY_FOR_DISTRIBUTION|PENDING_DEVELOPER_RELEASE|PENDING_APPLE_RELEASE|ACCEPTED/.test(st)) continue; phased[v.id] = (await c.maybe(`/v1/appStoreVersions/${v.id}/appStoreVersionPhasedRelease`))?.data ?? null; } // builds attached to a version but older than the five newest (so the train shows their number too) const known = new Set((builds.data as { id: string }[]).map(b => b.id)); const missing = [...new Set(versions.map(v => v.relationships?.build?.data?.id).filter((id): id is string => !!id && !known.has(id)))]; const attachedBuilds = missing.length ? ((await c.asc('GET', `/v1/builds?filter[id]=${missing.join(',')}&limit=${missing.length}`)).data ?? []) : []; // build id -> marketing version (CFBundleShortVersionString): a TestFlight-only version is a train at "testing" const buildVersions: Record = {}; const pre = new Map(((builds.included ?? []) as { id: string; type?: string; attributes?: { version?: string } }[]).map(p => [p.id, p.attributes?.version] as const)); for (const b of builds.data as { id: string; relationships?: { preReleaseVersion?: { data?: { id?: string } } } }[]) { const v = pre.get(b.relationships?.preReleaseVersion?.data?.id ?? ''); if (v) buildVersions[b.id] = v; } printJson({ app, versions, builds: builds.data, attachedBuilds, buildVersions, iaps, subs, phased, unresolved, emptyDrafts: [...empty], firstProductGaps: gaps, capabilityCheck: caps }); return; } console.log(`${app.attributes.name} · ${app.attributes.bundleId} · Apple ID ${appId}`); for (const v of versions) console.log(` version ${v.attributes.versionString.padEnd(8)} ${v.attributes.appVersionState ?? v.attributes.appStoreState}${v.relationships?.build?.data ? ' (build attached)' : ''}`); const pre = new Map((builds.included ?? []).map((p: { id: string; attributes: { version: string } }) => [p.id, p.attributes.version])); for (const b of builds.data) console.log(` build ${String(pre.get(b.relationships?.preReleaseVersion?.data?.id) ?? '?')} (${b.attributes.version}) ${b.attributes.processingState} ${b.attributes.uploadedDate?.slice(0, 10) ?? ''}`); for (const p of iaps) console.log(` iap ${p.attributes.productId.padEnd(40)} ${p.attributes.inAppPurchaseType} ${p.attributes.state}`); for (const s of subs) console.log(` review submission ${s.id} ${s.attributes.state}${empty.has(s.id) ? `: ${EMPTY_DRAFT}` : ''} ${s.attributes.submittedDate?.slice(0, 10) ?? ''}`.trimEnd()); for (const u of unresolved) { console.log(` review submission ${u.id} is back from App Review with UNRESOLVED_ISSUES; its items:`); for (const it of u.items) console.log(` ${itemLabel(it.kind, it.versionId).padEnd(36)} ${it.state}${it.state === 'REJECTED' ? ' (not resolved: Apple takes no resubmit until it is; apple resubmit marks it resolved)' : ''}`); const newest = builds.data[0]?.attributes?.version; console.log(` resubmit with: storebound apple resubmit --build ${newest ? ` (newest uploaded build: ${newest})` : ''}`); } for (const g of gaps) warn('apple', `${g.msg}; ${g.fix}`); if ('error' in caps) log('apple', `capabilities not compared: ${caps.error}`); else printCapabilityReport(caps, 'apple'); }