// Resubmit an App Store version after a rejection (Invalid Binary, Guideline 2.1, ...) with a new build. // storebound apple resubmit --build [--version 1.0.0] [--resolve-iaps] [--dry] [--yes] [--json] // 1. The rejected version (--version, else the one in the submission back from App Review, else the one in a // rejected state) gets build n attached, by explicit number (never "the latest"); it must be VALID. // 2. The open review submission is reused: the one with UNRESOLVED_ISSUES (it holds the version and its in-app // purchases) gets only the missing items, never a second draft next to it (seen live: a new empty draft and then // 409 ITEM_PART_OF_ANOTHER_SUBMISSION). In-app purchases and subscriptions of the config that are not in it yet go in. // 3. REJECTED items are marked resolved (PATCH reviewSubmissionItems/{id} resolved: true): the app version item, since // a fixed build goes in; in-app purchase / subscription items only with --resolve-iaps. Seen live (2026-10-12): // while the version item stays REJECTED, every submit answers 409 "Version is not ready to be submitted yet" and // the console's Resubmit button is disabled; resolved, the item goes to READY_FOR_REVIEW and the submit passes. // 4. PATCH submitted: true. On "not ready yet" the items are read again: a REJECTED one left over stops the run with // the item and how to resolve it (retrying never passes); only when none is left is the submit retried with backoff // (right after a build is attached), with heartbeat lines and a 10-minute limit. // Submitting is the owner's step: without --yes this is a dry run with the plan table. Not offered through MCP. // `storebound apple submit --build ` (cli/apple/submit.ts) runs the same steps for a first submission: the version // is --version, else the one an open submission holds, else the editable one (Prepare for Submission or rejected). // Under `serve` (the desktop's owner action) the plan table is printed next to the --json result. import { sellsOn } from '../lib/config'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; import { done, log, printJson, warn } from '../lib/log'; import { type BuildStep, formatRows, labelOf, planIapSubmission, REJECTED_APP, type ResolveScope, resubmitRows, toResolve } from '../lib/review-submit'; import { allSubscriptions } from '../lib/subscriptions'; import { ascClient, type J } from './asc'; import { readSubmitState, submitPlanned } from './iap-submit'; import { openSubmissions } from './submission'; /** App Store version states a first submission may send: the editable version (Prepare for Submission, a rejected * one, or one already Ready for Review in a draft). */ export const SUBMITTABLE_APP = new Set(['PREPARE_FOR_SUBMISSION', 'READY_FOR_REVIEW', ...REJECTED_APP]); export async function run(ctx: Ctx): Promise { await submitVersion(ctx, 'resubmit'); } /** apple resubmit (after a rejection) and apple submit (a version to App Review), with a build picked by number. */ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Promise { const TAG = mode; const { config, root } = ctx.loaded; const a = ctx.args; const number = a.get('build'); if (!number || !/^\d+(\.\d+)*$/.test(number)) throw new Error(`usage: apple ${mode} --build [--version x.y.z] [--dry] [--yes]: the build number you just uploaded (never "the latest")`); const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId); const appId = await c.app(); const ids = [...(config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id), ...allSubscriptions(config.subscriptions).filter(x => sellsOn(config, x.sub, 'apple')).map(x => x.sub.id)]; // the version to resubmit const versions = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`); const stateOf = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState; const open = await openSubmissions(c, appId); const held = new Set(open.filter(s => s.state === 'UNRESOLVED_ISSUES').flatMap(s => s.items.filter(i => i.kind === 'appStoreVersion').map(i => i.versionId))); const want = a.get('version'); const v: J | undefined = want ? versions.find((x: J) => x.attributes.versionString === want) : versions.find((x: J) => held.has(x.id)) ?? versions.find((x: J) => (mode === 'submit' ? SUBMITTABLE_APP : REJECTED_APP).has(stateOf(x))); if (!v) throw new Error(want ? `no App Store version ${want} on iOS` : mode === 'submit' ? 'no App Store version to submit (none in Prepare for Submission or rejected): storebound apple listing --version x.y.z first' : 'no rejected App Store version and no review submission with UNRESOLVED_ISSUES: nothing to resubmit (storebound apple status)'); if (mode === 'submit' && !SUBMITTABLE_APP.has(stateOf(v)) && !held.has(v.id)) throw new Error(`App Store version ${String(v.attributes.versionString)} is ${stateOf(v)}: only a version in Prepare for Submission (or a rejected one) can be submitted`); const versionString = String(v.attributes.versionString); if (!a.has('json')) log(TAG, `app version ${versionString}: ${stateOf(v)}`); // the build, by its number const b = (await c.all(`/v1/builds?filter[app]=${appId}&filter[version]=${number}&filter[preReleaseVersion.version]=${versionString}&filter[preReleaseVersion.platform]=IOS`))[0]; const cur = (await c.maybe(`/v1/appStoreVersions/${v.id}/build`))?.data; const current = cur ? String(cur.attributes?.version ?? cur.id) : null; const build: BuildStep = !b ? { number, id: null, current, action: 'refuse', reason: `build ${versionString} (${number}) not found (still uploading or processing? storebound apple status)` } : b.attributes.processingState !== 'VALID' ? { number, id: b.id, current, action: 'refuse', reason: `build ${number} is ${b.attributes.processingState}; wait until VALID` } : cur?.id === b.id ? { number, id: b.id, current, action: 'same', reason: `build ${number} is attached already` } : { number, id: b.id, current, action: 'attach', reason: `attach build ${number}${current ? ` instead of ${current}` : ''}` }; if (b && b.attributes.usesNonExemptEncryption == null) warn(TAG, `build ${number} has no export compliance answer yet: storebound apple encryption (Apple refuses the submission without it)`); const state = await readSubmitState(c, appId, ids, { appVersionId: v.id }); const plan = planIapSubmission(state, ids, { withVersion: true, appVersionId: v.id }); // --build is a fixed build for the version: its REJECTED item is marked resolved; IAP items only on --resolve-iaps const resolve: ResolveScope = { version: true, iaps: a.has('resolve-iaps') }; const rows = resubmitRows(state, plan, build, resolve); const marks = state.choice?.action === 'reuse' ? toResolve(state.choice.sub.items, resolve).map(i => ({ item: i.id ?? null, kind: i.kind, what: labelOf(state, i.versionId, i.kind) })) : []; const blockers = [...(build.action === 'refuse' ? [build.reason] : []), ...plan.blockers]; const dry = a.has('dry') || !a.has('yes'); if (a.has('json')) { printJson({ dryRun: dry, version: { id: v.id, versionString, state: stateOf(v) }, build, submission: state.choice ?? null, resolve: marks, rows, skipped: plan.skipped, blockers }); // the desktop's owner dialog shows the output text: the plan table goes with the result there if (process.env.STOREBOUND_MACHINE === '1') { console.log(formatRows(rows)); for (const x of blockers) warn(TAG, x); } if (blockers.length) process.exitCode = 1; if (dry || blockers.length || state.choice?.action === 'busy') return; } else { console.log(formatRows(rows)); for (const s of plan.skipped) log(TAG, `not added ${s.id}: ${s.why}`); for (const x of blockers) warn(TAG, x); } if (blockers.length) { process.exitCode = 1; done(TAG, 'nothing sent: fix the blockers above'); return; } if (state.choice?.action === 'busy') { done(TAG, `nothing to ${mode}: ${state.choice.why}`); return; } if (dry) { done(TAG, a.has('dry') ? 'dry run: nothing sent' : `dry run (no --yes): nothing sent. ${mode === 'submit' ? 'Submitting' : 'Resubmitting'} is the owner's step: after the owner's yes, run it again with --yes`); return; } if (build.action === 'attach') { await c.asc('PATCH', `/v1/appStoreVersions/${v.id}/relationships/build`, { data: { type: 'builds', id: build.id } }); log(TAG, `build ${number} attached to ${versionString}`); } await submitPlanned(ctx, c, { appId, ids, state, tag: TAG, withVersion: true, appVersionId: v.id, quiet: true, resolve }); }