apple resubmit: REJECTED review submission items are marked resolved before the submit (PATCH reviewSubmissionItems resolved: true; the version item by default, IAP / subscription items with --resolve-iaps), plan table 'mark resolved' rows; on 409 'not ready yet' the items are re-read and a REJECTED item out of scope stops the run at once naming it and how to resolve it, backoff only when none is left; an empty READY_FOR_REVIEW draft is never canceled (409 'not in cancellable state'): left as is, no cancel command, apple status labels it harmless and treats the cancel 409 as such; fake ASC blocks submit on REJECTED items and refuses to cancel empty drafts. Pitfall 30

This commit is contained in:
CRTkafa 2026-10-12 10:45:05 +08:00
parent 0da5ee9d01
commit 1724296e59
17 changed files with 291 additions and 78 deletions

View file

@ -160,6 +160,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'promotedPurchases_createInstance': 'lib/offers',
'promotedPurchases_updateInstance': 'lib/offers',
'reviewSubmissionItems_createInstance': 'apple/submission lib/cpp-events lib/review-submit',
'reviewSubmissionItems_updateInstance': 'apple/submission',
'reviewSubmissions_createInstance': 'apple/cpp apple/events apple/submission lib/review-submit',
'reviewSubmissions_getCollection': 'apple/events apple/iap-submit apple/status apple/submission',
'reviewSubmissions_getInstance': 'apple/submission',

View file

@ -1,5 +1,5 @@
// Send in-app purchases and subscriptions to App Review through a review submission (the owner's action).
// storebound apple iap-submit [--only id1,id2] [--with-version] [--new-version] [--no-submit] [--dry] [--yes] [--json]
// storebound apple iap-submit [--only id1,id2] [--with-version] [--new-version] [--resolve-iaps] [--no-submit] [--dry] [--yes] [--json]
// Default: every product and subscription of the config sold on the App Store. The plan (cli/lib/review-submit.ts)
// says which version of each goes in, which items are first of their type (those need an app version in the same
// submission: --with-version adds the editable one, and then submitting submits the app too), and what is left out.
@ -9,16 +9,19 @@
// The submission is the app's open one when there is one (chooseSubmission): a draft, or one back from App Review with
// UNRESOLVED_ISSUES, which gets only the missing items and is resubmitted; while one waits for or is in review nothing
// is sent. A new draft is created only when none is open (storebound apple resubmit uses the same steps).
// --resolve-iaps: REJECTED in-app purchase / subscription items of a submission back from App Review are marked
// resolved before the submit (Apple refuses the submit with "not ready yet" while one is left). A REJECTED app version
// item is left to apple resubmit --build <n>, which attaches the fixed build first.
import { sellsOn } from '../lib/config';
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { done, log, printJson, warn } from '../lib/log';
import { allSubscriptions } from '../lib/subscriptions';
import { bindIds, chooseSubmission, latestVersion, newVersionReq, type PIap, planIapSubmission, type PSub, type SubmitState, versionsOf, willSubmit } from '../lib/review-submit';
import { bindIds, chooseSubmission, EMPTY_DRAFT, labelOf, type ResolveScope, resolveHint, toResolve, latestVersion, newVersionReq, type PIap, planIapSubmission, type PSub, type SubmitState, versionsOf, willSubmit } from '../lib/review-submit';
import { readJournal } from '../lib/upsert';
import { type Asc, ascClient, type J } from './asc';
import { describeReq, execute } from './jsonapi';
import { addItem, cancelUnusedDraft, draftJournalKey, ensureDraft, openSubmissions, OtherSubmissionError, submitWithRetry } from './submission';
import { addItem, draftJournalKey, ensureDraft, leaveUnusedDraft, openSubmissions, OtherSubmissionError, resolveRejected, submitWithRetry } from './submission';
const TAG = 'iap-submit';
@ -76,7 +79,7 @@ export async function run(ctx: Ctx): Promise<void> {
if (!dry) state = await readSubmitState(c, appId, ids);
}
}
await submitPlanned(ctx, c, { appId, ids, state, tag: TAG, withVersion: a.has('with-version') });
await submitPlanned(ctx, c, { appId, ids, state, tag: TAG, withVersion: a.has('with-version'), resolve: { version: false, iaps: a.has('resolve-iaps') } });
}
/** Lines that say which submission is used and what else is open next to it. */
@ -84,7 +87,7 @@ export function choiceLines(state: SubmitState): string[] {
const ch = state.choice;
if (!ch) return [];
const out = [ch.why];
if (ch.action === 'reuse') for (const d of ch.strayDrafts) out.push(`draft ${d.id} (${d.items.length} item(s)) is open next to it${d.items.length ? '' : ': empty, left over from an earlier run'}`);
if (ch.action === 'reuse') for (const d of ch.strayDrafts) out.push(`draft ${d.id} (${d.items.length} item(s)) is open next to it${d.items.length ? '' : `: ${EMPTY_DRAFT}`}`);
return out;
}
@ -94,6 +97,8 @@ export interface SubmitOpts {
appVersionId?: string;
/** the caller printed its own plan (resubmit) */
quiet?: boolean;
/** REJECTED items marked resolved before the submit (resubmit: the version item; --resolve-iaps: the rest) */
resolve?: ResolveScope;
/** second pass after a 409 named another submission */
retried?: boolean;
}
@ -101,8 +106,9 @@ export interface SubmitOpts {
/**
* Plans and sends a review submission: reuses the open one (chooseSubmission), adds only the missing items, then
* submits (PATCH submitted: true, retried while Apple says "not ready yet"). A draft is created only when no open
* submission exists; one this run created and could not use (409 ITEM_PART_OF_ANOTHER_SUBMISSION) is canceled again,
* and the submission the 409 named is used instead. Shared by iap-submit and resubmit.
* submission exists; one this run created and could not use (409 ITEM_PART_OF_ANOTHER_SUBMISSION) is left as is (an empty draft is not cancellable),
* and the submission the 409 named is used instead. REJECTED items in `o.resolve` are marked resolved before the
* submit. Shared by iap-submit and resubmit.
*/
export async function submitPlanned(ctx: Ctx, c: Asc, o: SubmitOpts): Promise<void> {
const { root } = ctx.loaded;
@ -125,6 +131,13 @@ export async function submitPlanned(ctx: Ctx, c: Asc, o: SubmitOpts): Promise<vo
if (plan.firstOfType.length) log(tag, `first ${plan.firstOfType.join(' / ')} of the app: needs an app version in the same submission`);
for (const b of plan.blockers) warn(tag, b);
}
if (!a.has('json') && state.choice?.action === 'reuse') {
const marked = new Set(toResolve(state.choice.sub.items, o.resolve));
for (const it of state.choice.sub.items.filter(i => i.state === 'REJECTED')) {
if (marked.has(it)) log(tag, `${labelOf(state, it.versionId, it.kind)}: REJECTED, marked resolved before the submit`);
else warn(tag, `${labelOf(state, it.versionId, it.kind)} is REJECTED in ${state.choice.sub.id}: Apple takes no submit while it is not resolved; ${resolveHint(it.kind)}`);
}
}
if (busy) { done(tag, `nothing to submit: ${state.choice!.why}`); return; }
if (plan.blockers.length) { process.exitCode = 1; done(tag, 'nothing sent: fix the blockers above'); return; }
if (!submitting) { done(tag, 'nothing to submit'); return; }
@ -157,7 +170,7 @@ export async function submitPlanned(ctx: Ctx, c: Asc, o: SubmitOpts): Promise<vo
} catch (e) {
if (!(e instanceof OtherSubmissionError)) throw e;
warn(tag, e.message.split('\n')[0]!);
if (created) await cancelUnusedDraft(c, sid, tag);
if (created) await leaveUnusedDraft(c, sid, tag);
if (o.retried || e.otherId === '?') throw e;
// use the submission the 409 named: read the state again with it and go once more
log(tag, `using review submission ${e.otherId} instead`);
@ -166,22 +179,30 @@ export async function submitPlanned(ctx: Ctx, c: Asc, o: SubmitOpts): Promise<vo
await submitPlanned(ctx, c, { ...o, state: again, retried: true, quiet: true });
return;
}
await cleanStrayDrafts(c, o.appId, root, state, tag);
cleanStrayDrafts(root, o.appId, state, tag);
// a REJECTED item blocks every submit ("not ready yet") until it is marked resolved; the console's Resubmit too
const label = (it: { kind: string; versionId: string }): string => labelOf(state, it.versionId, it.kind);
const verb = resubmit ? 'Resubmit' : 'Submit';
if (a.has('no-submit')) { done(tag, `${resubmit ? 'review submission' : 'draft review submission'} ${sid} ready: press "${resubmit ? 'Resubmit to App Review' : 'Submit for Review'}" in App Store Connect (Apps > Distribution > App Review)`); return; }
if (a.has('no-submit')) {
if (resubmit) await resolveRejected(c, sid, o.resolve, tag, label);
done(tag, `${resubmit ? 'review submission' : 'draft review submission'} ${sid} ready: press "${resubmit ? 'Resubmit to App Review' : 'Submit for Review'}" in App Store Connect (Apps > Distribution > App Review)`);
return;
}
if (!(await confirm(ctx, `${verb} review submission ${sid} (${plan.items.length ? what : 'as it is'}) to App Review now?${plan.appVersion || resubmit ? ' This submits the app version too.' : ''}`))) {
done(tag, `${sid} kept; submit it in App Store Connect or re-run`); return;
}
await submitWithRetry(c, sid, tag);
if (resubmit) await resolveRejected(c, sid, o.resolve, tag, label);
await submitWithRetry(c, sid, tag, { resolve: o.resolve, label });
done(tag, `review submission ${sid} ${resubmit ? 'resubmitted' : 'submitted'} (storebound apple status shows its state)`);
}
/** Empty drafts next to a reused submission: the ones storebound created (journal) are canceled, others are named. */
async function cleanStrayDrafts(c: Asc, appId: string, root: string, state: SubmitState, tag: string): Promise<void> {
/** Drafts next to a reused submission. An empty one is left as is: App Store Connect answers 409 "Resource is not in
* cancellable state" for it (seen live) and it does no harm; one with items is named with the cancel command. */
function cleanStrayDrafts(root: string, appId: string, state: SubmitState, tag: string): void {
if (state.choice?.action !== 'reuse') return;
const mine = readJournal(root)[draftJournalKey(appId)]?.id;
for (const d of state.choice.strayDrafts) {
if (!d.items.length && d.id === mine) { await cancelUnusedDraft(c, d.id, tag); continue; }
if (!d.items.length) { log(tag, `draft ${d.id}${d.id === mine ? ' (created by storebound)' : ''}: ${EMPTY_DRAFT}; left as it is`); continue; }
warn(tag, `draft ${d.id} (${d.items.length} item(s)) is still open next to ${state.choice.sub.id}; cancel it if it is not needed: storebound apple status --cancel-submission ${d.id}`);
}
}

View file

@ -1,18 +1,23 @@
// Resubmit an App Store version after a rejection (Invalid Binary, Guideline 2.1, ...) with a new build.
// storebound apple resubmit --build <n> [--version 1.0.0] [--dry] [--yes] [--json]
// storebound apple resubmit --build <n> [--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. PATCH submitted: true, retried with backoff while Apple says "Version is not ready to be submitted yet" (right
// after a build is attached), with heartbeat lines and a 10-minute limit.
// 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.
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, planIapSubmission, REJECTED_APP, resubmitRows } from '../lib/review-submit';
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';
@ -53,11 +58,14 @@ export async function run(ctx: Ctx): Promise<void> {
const state = await readSubmitState(c, appId, ids, { appVersionId: v.id });
const plan = planIapSubmission(state, ids, { withVersion: true, appVersionId: v.id });
const rows = resubmitRows(state, plan, build);
// --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, rows, skipped: plan.skipped, blockers });
printJson({ dryRun: dry, version: { id: v.id, versionString, state: stateOf(v) }, build, submission: state.choice ?? null, resolve: marks, rows, skipped: plan.skipped, blockers });
if (blockers.length) process.exitCode = 1;
if (dry || blockers.length || state.choice?.action === 'busy') return;
} else {
@ -76,5 +84,5 @@ export async function run(ctx: Ctx): Promise<void> {
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 });
await submitPlanned(ctx, c, { appId, ids, state, tag: TAG, withVersion: true, appVersionId: v.id, quiet: true, resolve });
}

View file

@ -2,6 +2,8 @@
// 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 <id> [--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 <n>
// Wait for App Review (polls with backoff: 1 min, x1.5, max 15 min):
@ -11,8 +13,9 @@ import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { log, printJson } from '../lib/log';
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 { readItems, type SubItem } from './submission';
import { notCancellable, readItems, type SubItem } from './submission';
async function waitReview(ctx: Ctx, c: Asc, appId: string): Promise<never> {
const want = ctx.args.get('version');
@ -47,7 +50,14 @@ export async function run(ctx: Ctx): Promise<void> {
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;
await c.asc('PATCH', `/v1/reviewSubmissions/${cancel}`, { data: { type: 'reviewSubmissions', id: cancel, attributes: { canceled: true } } });
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;
}
@ -60,6 +70,9 @@ export async function run(ctx: Ctx): Promise<void> {
// 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<string>();
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);
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}`;
@ -83,7 +96,7 @@ export async function run(ctx: Ctx): Promise<void> {
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 });
printJson({ app, versions, builds: builds.data, attachedBuilds, buildVersions, iaps, subs, phased, unresolved, emptyDrafts: [...empty] });
return;
}
@ -92,10 +105,10 @@ export async function run(ctx: Ctx): Promise<void> {
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} ${s.attributes.submittedDate?.slice(0, 10) ?? ''}`);
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}`);
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 <n>${newest ? ` (newest uploaded build: ${newest})` : ''}`);
}

View file

@ -4,6 +4,7 @@
// submit. Submitting itself (PATCH submitted: true) stays the owner's confirmed step in those commands.
import { log, warn } from '../lib/log';
import { createOnce, findOrCreate } from '../lib/upsert';
import { EMPTY_DRAFT, resolveHint, type ResolveScope, toResolve } from '../lib/review-submit';
import type { Asc, J } from './asc';
import { one } from './jsonapi';
@ -20,7 +21,7 @@ export async function draftSubmission(c: Asc, appId: string, root: string, tag:
/** The journal key of the draft storebound created (cli/lib/upsert.ts). */
export const draftJournalKey = (appId: string): string => `apple:${appId}:reviewSubmission:draft`;
/** Like draftSubmission, and says whether this run created the draft (so it can be canceled again if unused). */
/** Like draftSubmission, and says whether this run created the draft (an unused empty one is left: not cancellable). */
export async function ensureDraft(c: Asc, appId: string, root: string, tag: string): Promise<{ id: string; created: boolean }> {
const r = await findOrCreate<string>({ what: 'draft review submission', tag, unique: true,
find: () => findDraft(c, appId),
@ -102,12 +103,35 @@ export class OtherSubmissionError extends Error {
constructor(readonly otherId: string, readonly kind: string, readonly itemId: string, message: string) { super(message); this.name = 'OtherSubmissionError'; }
}
/** Cancels a draft this run created and could not use (PATCH canceled: true), only while it is still empty. */
export async function cancelUnusedDraft(c: Asc, sid: string, tag: string): Promise<boolean> {
if ((await readItems(c, sid)).length) { warn(tag, `draft ${sid} is not empty; left as it is`); return false; }
await c.asc('PATCH', `/v1/reviewSubmissions/${sid}`, { data: { type: 'reviewSubmissions', id: sid, attributes: { canceled: true } } });
log(tag, `empty draft ${sid} created by this run canceled (not used)`);
return true;
/** A draft this run created and could not use. Not canceled: App Store Connect answers 409 "Resource is not in
* cancellable state" for an empty READY_FOR_REVIEW draft (seen live). It is harmless and a later run reuses it. */
export async function leaveUnusedDraft(c: Asc, sid: string, tag: string): Promise<void> {
const n = (await readItems(c, sid)).length;
if (n) warn(tag, `draft ${sid} (${n} item(s)) is not empty; left as it is`);
else log(tag, `draft ${sid} created by this run was not used: ${EMPTY_DRAFT}; a later run reuses it`);
}
/** Apple's 409 when an empty draft is canceled: "Resource is not in cancellable state". Pure. */
export const notCancellable = (e: unknown): boolean => /-> 409\b/.test(String((e as Error)?.message ?? e)) && /not in cancellable state/i.test(String((e as Error)?.message ?? e));
/** Marks one item of a submission back from App Review resolved (seen live: the REJECTED item goes to READY_FOR_REVIEW,
* and only then does PATCH submitted: true pass). */
export async function resolveItem(c: Asc, itemId: string, label: string, tag: string): Promise<void> {
await c.asc('PATCH', `/v1/reviewSubmissionItems/${itemId}`, { data: { type: 'reviewSubmissionItems', id: itemId, attributes: { resolved: true } } });
log(tag, `${label}: marked resolved (item ${itemId})`);
}
/** Marks the REJECTED items of `sid` that `scope` covers resolved; returns the ids it marked. */
export async function resolveRejected(c: Asc, sid: string, scope: ResolveScope | undefined, tag: string, label: (it: SubItem) => string = it => `${it.kind} ${it.versionId}`): Promise<string[]> {
const out: string[] = [];
for (const it of toResolve(await readItems(c, sid), scope)) { await resolveItem(c, it.id, label(it), tag); out.push(it.id); }
return out;
}
export interface SubmitRetryOpts {
/** REJECTED items the run may mark resolved when Apple says "not ready" */
resolve?: ResolveScope;
label?: (it: SubItem) => string;
}
/** Waits of the submit retry (injectable: tests replace sleep and now, nothing waits for real). */
@ -122,11 +146,16 @@ export const submitTiming = {
/** Apple's 409 right after a new build is attached: "Version is not ready to be submitted yet, please try again later". Pure. */
export const notReadyYet = (e: unknown): boolean => /-> 409\b/.test(String((e as Error)?.message ?? e)) && /not ready to be submitted|try again later/i.test(String((e as Error)?.message ?? e));
/** PATCH submitted: true, retried with backoff (15 s, 30 s, 60 s, then every 2 min; heartbeat lines while waiting)
* while Apple answers "not ready to be submitted yet", up to submitTiming.timeoutMs. Any other error is thrown at once. */
export async function submitWithRetry(c: Asc, sid: string, tag: string): Promise<void> {
/** PATCH submitted: true. While Apple answers "not ready to be submitted yet" the submission's items are read again
* first: a REJECTED item in `o.resolve` is marked resolved and the submit tried again at once; a REJECTED item out of
* scope stops the run with the item and how to resolve it (seen live: retrying never passes while one is left). Only
* when nothing is unresolved is the submit retried with backoff (15 s, 30 s, 60 s, then every 2 min; heartbeat lines),
* up to submitTiming.timeoutMs. Any other error is thrown at once. */
export async function submitWithRetry(c: Asc, sid: string, tag: string, o: SubmitRetryOpts = {}): Promise<void> {
const t = submitTiming;
const start = t.now();
const label = o.label ?? ((it: SubItem) => `${it.kind} ${it.versionId}`);
const tried = new Set<string>();
for (let attempt = 1; ; attempt++) {
try {
await c.asc('PATCH', `/v1/reviewSubmissions/${sid}`, { data: { type: 'reviewSubmissions', id: sid, attributes: { submitted: true } } });
@ -134,14 +163,25 @@ export async function submitWithRetry(c: Asc, sid: string, tag: string): Promise
return;
} catch (e) {
if (!notReadyYet(e)) throw e;
const rejected = (await readItems(c, sid)).filter(i => i.state === 'REJECTED');
const mine = toResolve(rejected, o.resolve).filter(i => !tried.has(i.id));
if (mine.length) {
for (const it of mine) { tried.add(it.id); await resolveItem(c, it.id, label(it), tag); }
continue;
}
if (rejected.length) {
throw new Error(`review submission ${sid}: Apple answers "not ready to be submitted yet" because ${rejected.length === 1 ? 'an item is' : `${rejected.length} items are`} still REJECTED `
+ `and not marked resolved; retrying would never pass, so nothing more is sent:\n${rejected.map(i => ` ${label(i)} (item ${i.id}): ${resolveHint(i.kind)}`).join('\n')}\n`
+ `Last answer: ${(e as Error).message}`);
}
const elapsed = t.now() - start;
const delay = t.delaysMs[Math.min(attempt - 1, t.delaysMs.length - 1)]!;
if (elapsed + delay > t.timeoutMs) {
throw new Error(`review submission ${sid}: Apple still answers "not ready to be submitted yet" after ${Math.round(elapsed / 1000)} s and ${attempt} tries `
+ `(a build attached a moment ago is still being prepared for review). Nothing else changed: re-run the same command in a few minutes; it reuses submission ${sid}.\n`
+ `(a build attached a moment ago is still being prepared for review; no item is left REJECTED). Nothing else changed: re-run the same command in a few minutes; it reuses submission ${sid}.\n`
+ `Last answer: ${(e as Error).message}`);
}
log(tag, `review submission ${sid}: not ready to be submitted yet (try ${attempt}, ${Math.round(elapsed / 1000)} s so far, gives up after ${Math.round(t.timeoutMs / 60_000)} min); next try in ${Math.round(delay / 1000)} s`);
log(tag, `review submission ${sid}: not ready to be submitted yet, no item left REJECTED (try ${attempt}, ${Math.round(elapsed / 1000)} s so far, gives up after ${Math.round(t.timeoutMs / 60_000)} min); next try in ${Math.round(delay / 1000)} s`);
for (let left = delay; left > 0;) {
const step = Math.min(left, t.heartbeatMs);
await t.sleep(step);

View file

@ -92,6 +92,7 @@
"apple iap-submit": [
"new-version",
"no-submit",
"resolve-iaps",
"with-version"
],
"apple listing": [
@ -155,6 +156,7 @@
"build",
"new-version",
"no-submit",
"resolve-iaps",
"version",
"with-version"
],
@ -826,6 +828,7 @@
"release-date",
"remove",
"render",
"resolve-iaps",
"resume",
"scenes",
"scheme",
@ -945,7 +948,7 @@
"storebound apple iap-review [--dry]"
],
"apple iap-submit": [
"storebound apple iap-submit [--only id1,id2] [--with-version] [--new-version] [--no-submit] [--dry] [--yes] [--json]"
"storebound apple iap-submit [--only id1,id2] [--with-version] [--new-version] [--resolve-iaps] [--no-submit] [--dry] [--yes] [--json]"
],
"apple listing": [
"storebound apple listing --version 1.0.0 [--build 3] [--include-drafts] [--dry] [--json]",
@ -979,7 +982,7 @@
"storebound apple release --manual-release [--version 1.2.0] [--yes]"
],
"apple resubmit": [
"storebound apple resubmit --build <n> [--version 1.0.0] [--dry] [--yes] [--json]"
"storebound apple resubmit --build <n> [--version 1.0.0] [--resolve-iaps] [--dry] [--yes] [--json]"
],
"apple review-details": [
"storebound apple review-details [--contact-from <Apple ID of another app>] [--dry]"

View file

@ -61,7 +61,30 @@ export interface SubmitState {
choice?: SubmissionChoice;
}
export interface OpenSub { id: string; state: string; items: { kind: string; versionId: string; state: string }[] }
export interface OpenSub { id: string; state: string; items: { id?: string; kind: string; versionId: string; state: string }[] }
/** Which REJECTED items of a submission back from App Review a run marks resolved (PATCH reviewSubmissionItems
* resolved: true) before it submits. Apple answers every submit with 409 "Version is not ready to be submitted yet"
* while one is left (seen live for 25 minutes after an Invalid Binary rejection; retrying never helps).
* version: the appStoreVersion item (apple resubmit attaches a new build); iaps: in-app purchase, subscription and
* subscription group items (only on --resolve-iaps: the owner fixed what App Review asked for). */
export interface ResolveScope { version: boolean; iaps: boolean }
/** The REJECTED items `scope` covers. Pure. */
export function toResolve<T extends { kind: string; state: string }>(items: T[], scope?: ResolveScope): T[] {
if (!scope) return [];
return items.filter(i => i.state === 'REJECTED' && (i.kind === 'appStoreVersion' ? scope.version : scope.iaps));
}
/** How to resolve a REJECTED item a run left alone. Pure. */
export function resolveHint(kind: string): string {
return kind === 'appStoreVersion'
? 'attach a fixed build and mark it resolved: storebound apple resubmit --build <n>'
: 'fix what App Review asked for, then mark it resolved with --resolve-iaps (storebound apple resubmit --build <n> --resolve-iaps, or apple iap-submit --resolve-iaps)';
}
/** What the plan does with an empty draft next to the reused submission. */
export const EMPTY_DRAFT = 'empty draft (harmless; App Store Connect keeps it, not cancellable)';
export type SubmissionChoice =
| { action: 'reuse'; sub: OpenSub; why: string; strayDrafts: OpenSub[] }
| { action: 'busy'; sub: OpenSub; why: string }
@ -221,16 +244,20 @@ export function labelOf(s: SubmitState, versionId: string, kind: string): string
}
/** The dry-run table of `apple resubmit`: build, submission, every item (in it already or added), submit. Pure. */
export function resubmitRows(s: SubmitState, plan: SubmitPlan, build: BuildStep): PlanRow[] {
export function resubmitRows(s: SubmitState, plan: SubmitPlan, build: BuildStep, resolve?: ResolveScope): PlanRow[] {
const rows: PlanRow[] = [{ step: 'build', what: `build ${build.number}`, now: build.current ? `attached: ${build.current}` : 'none attached', action: build.action === 'refuse' ? `REFUSE: ${build.reason}` : build.reason }];
const ch = s.choice;
if (ch?.action === 'busy') rows.push({ step: 'submission', what: ch.sub.id, now: ch.sub.state, action: 'nothing to do until App Review finishes' });
else if (ch?.action === 'reuse') rows.push({ step: 'submission', what: ch.sub.id, now: ch.sub.state, action: ch.sub.state === 'UNRESOLVED_ISSUES' ? 'reuse (resubmit, no new draft)' : 'reuse the draft' });
else rows.push({ step: 'submission', what: 'new draft', now: '-', action: 'create (no open submission)' });
if (ch && ch.action !== 'create') for (const it of ch.sub.items) rows.push({ step: 'item', what: labelOf(s, it.versionId, it.kind), now: it.state, action: 'in it already' });
if (ch && ch.action !== 'create') {
const marked = new Set(ch.action === 'reuse' ? toResolve(ch.sub.items, resolve) : []);
for (const it of ch.sub.items) rows.push({ step: 'item', what: labelOf(s, it.versionId, it.kind), now: it.state,
action: marked.has(it) ? 'in it already; mark resolved' : it.state === 'REJECTED' ? `REJECTED, left: ${resolveHint(it.kind)}` : 'in it already' });
}
for (const it of plan.items) rows.push({ step: 'item', what: it.label, now: '-', action: 'add' });
if (ch?.action === 'reuse') for (const d of ch.strayDrafts) rows.push({ step: 'stray draft', what: d.id, now: `${d.state}, ${d.items.length} item(s)`, action: d.items.length ? 'left as it is (named)' : 'canceled if storebound created it' });
if (ch?.action !== 'busy') rows.push({ step: 'submit', what: 'PATCH submitted: true', now: '-', action: 'owner step (--yes); retried while Apple says "not ready yet"' });
if (ch?.action === 'reuse') for (const d of ch.strayDrafts) rows.push({ step: 'stray draft', what: d.id, now: `${d.state}, ${d.items.length} item(s)`, action: d.items.length ? 'left as it is (named)' : `left as it is: ${EMPTY_DRAFT}` });
if (ch?.action !== 'busy') rows.push({ step: 'submit', what: 'PATCH submitted: true', now: '-', action: 'owner step (--yes); retried while Apple says "not ready yet" and no item is left REJECTED' });
return rows;
}