apple resubmit after a rejection: iap-submit reuses the open review submission (UNRESOLVED_ISSUES gets only missing items and is resubmitted, a draft is reused, one waiting for or in review means nothing is sent), never a second draft; 409 ITEM_PART_OF_ANOTHER_SUBMISSION names the other submission instead of the list-lag message, a draft the run created is canceled and the named one used; submit retried with backoff and heartbeat while Apple says not ready yet; new apple resubmit --build <n> (build by number, plan table, --yes); apple status lists UNRESOLVED_ISSUES items and the resubmit command. Pitfall 30

This commit is contained in:
CRTkafa 2026-10-12 10:33:48 +08:00
parent c8213be2b6
commit 0da5ee9d01
21 changed files with 726 additions and 80 deletions

View file

@ -65,7 +65,7 @@ const READ: string[][] = [
const WAIT: string[][] = [['apple', 'testflight', 'wait']];
/** Never executed by a flow: the owner does these (AGENTS.md rule 1). */
const OWNER: string[][] = [
['apple', 'release'], ['play', 'rollout'], ['reviews', 'send'], ['prices', 'import'], ['apple', 'iap-submit'], ['apple', 'ppo', 'start'], ['apple', 'ppo', 'stop'],
['apple', 'release'], ['play', 'rollout'], ['reviews', 'send'], ['prices', 'import'], ['apple', 'iap-submit'], ['apple', 'resubmit'], ['apple', 'ppo', 'start'], ['apple', 'ppo', 'stop'],
['apple', 'cpp', 'submit'], ['apple', 'cpp', 'visible'], ['apple', 'events', 'submit'], ['apple', 'preorder', 'enable'], ['apple', 'preorder', 'end'],
['apple', 'testflight', 'testers'], ['apple', 'testflight', 'expire'], ['apple', 'offer-codes'], ['apple', 'win-back'], ['ads', 'apple', 'bid'],
['ads', 'apple', 'budget'], ['ads', 'apple', 'pause'], ['apple', 'adhoc', 'register'],

View file

@ -53,8 +53,39 @@ export interface SubmitState {
iaps: PIap[];
subs: PSub[];
groups: PGroup[];
/** The app's draft review submission (state READY_FOR_REVIEW), if one exists, and the version ids already in it. */
draft?: { id: string; itemVersionIds: string[] } | null;
/** The submission this run uses, if one exists, and the version ids already in it: a draft (READY_FOR_REVIEW), or
* one back from App Review (UNRESOLVED_ISSUES), which is resubmitted, never replaced by a second one. */
draft?: { id: string; itemVersionIds: string[]; state?: string } | null;
/** Every open review submission (chooseSubmission) and what was decided. */
open?: OpenSub[];
choice?: SubmissionChoice;
}
export interface OpenSub { id: string; state: string; items: { kind: string; versionId: string; state: string }[] }
export type SubmissionChoice =
| { action: 'reuse'; sub: OpenSub; why: string; strayDrafts: OpenSub[] }
| { action: 'busy'; sub: OpenSub; why: string }
| { action: 'create'; why: string };
/**
* Which review submission to use, from the app's open ones (READY_FOR_REVIEW, UNRESOLVED_ISSUES, WAITING_FOR_REVIEW,
* IN_REVIEW). Never a second one while one of them exists (seen live: a new empty draft next to a rejected
* submission, then 409 ITEM_PART_OF_ANOTHER_SUBMISSION for the app version):
* - one waiting for or in review: nothing can be submitted until App Review is done (busy);
* - one with UNRESOLVED_ISSUES (rejected, preferably the one holding `appVersionId`): reused and resubmitted, with
* only the missing items added; empty drafts next to it are strays;
* - a draft (READY_FOR_REVIEW): reused;
* - none: a new draft. Pure.
*/
export function chooseSubmission(open: OpenSub[], o: { appVersionId?: string } = {}): SubmissionChoice {
const busy = open.find(x => IN_REVIEW.has(x.state));
if (busy) return { action: 'busy', sub: busy, why: `review submission ${busy.id} is ${busy.state}: nothing more can be submitted until App Review finishes it (storebound apple status --wait-review)` };
const unresolved = open.filter(x => x.state === 'UNRESOLVED_ISSUES');
const holder = unresolved.find(x => o.appVersionId && x.items.some(i => i.versionId === o.appVersionId)) ?? unresolved[0];
const drafts = open.filter(x => x.state === 'READY_FOR_REVIEW');
if (holder) return { action: 'reuse', sub: holder, strayDrafts: drafts, why: `review submission ${holder.id} has UNRESOLVED_ISSUES (back from App Review)${holder.items.some(i => i.versionId === o.appVersionId) ? ' and holds the app version' : ''}: reused and resubmitted, no new draft` };
if (drafts[0]) return { action: 'reuse', sub: drafts[0], strayDrafts: drafts.slice(1), why: `draft review submission ${drafts[0].id} reused` };
return { action: 'create', why: 'no open review submission: a new draft' };
}
export interface PlanItem { kind: ItemKind; versionId: string; label: string }
@ -88,7 +119,7 @@ export function latestVersion(vs: PVersion[]): PVersion | undefined {
* Plan for submitting `want` (product ids of in-app purchases and subscriptions). withVersion: put the editable app
* version into the same submission (needed for the first item of a type, or while the app has no approved version).
*/
export function planIapSubmission(s: SubmitState, want: string[], o: { withVersion?: boolean } = {}): SubmitPlan {
export function planIapSubmission(s: SubmitState, want: string[], o: { withVersion?: boolean; appVersionId?: string } = {}): SubmitPlan {
const plan: SubmitPlan = { items: [], skipped: [], firstOfType: [], hasApprovedVersion: s.appVersions.some(v => APPROVED_APP.has(v.state)), needsAppVersion: false, blockers: [], prepare: [],
submit: { method: 'PATCH', path: '/v1/reviewSubmissions/${submission}', note: 'submit for App Review', body: {} } };
const approvedTypes = new Set([...s.iaps.filter(p => APPROVED_PRODUCT.has(p.state)).map(p => p.type), ...(s.subs.some(x => APPROVED_PRODUCT.has(x.state)) ? ['AUTO_RENEWABLE'] : [])]);
@ -129,12 +160,14 @@ export function planIapSubmission(s: SubmitState, want: string[], o: { withVersi
plan.items.push({ kind: 'subscriptionGroupVersion', versionId: v.id, label: `subscription group "${g.name}" (not approved yet, goes with its subscriptions)` });
}
plan.firstOfType = [...firstTypes].map(typeLabel);
plan.needsAppVersion = plan.items.length > 0 && (firstTypes.size > 0 || !plan.hasApprovedVersion);
const forced = o.appVersionId ? s.appVersions.find(v => v.id === o.appVersionId) : undefined;
plan.needsAppVersion = !!forced || (plan.items.length > 0 && (firstTypes.size > 0 || !plan.hasApprovedVersion));
if (plan.needsAppVersion) {
const editable = s.appVersions.find(v => ['PREPARE_FOR_SUBMISSION', 'DEVELOPER_REJECTED', 'REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY', 'READY_FOR_REVIEW'].includes(v.state));
// resubmit: the version named by the caller; else the editable one
const editable = forced ?? s.appVersions.find(v => ['PREPARE_FOR_SUBMISSION', 'DEVELOPER_REJECTED', 'REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY', 'READY_FOR_REVIEW'].includes(v.state));
const why = firstTypes.size ? `first ${plan.firstOfType.join(' / ')} of this app: Apple reviews it only together with a new app version`
: 'the app has no approved version yet: in-app purchases go together with an app version';
if (!o.withVersion) plan.blockers.push(`${why}. Add --with-version (the editable version ${editable ? editable.versionString : '(none yet: storebound apple listing --version x.y.z --build N)'} goes into the same submission; submitting it submits the app)`);
if (!o.withVersion && !forced) plan.blockers.push(`${why}. Add --with-version (the editable version ${editable ? editable.versionString : '(none yet: storebound apple listing --version x.y.z --build N)'} goes into the same submission; submitting it submits the app)`);
else if (!editable) plan.blockers.push(`${why}, and there is no editable app version: storebound apple listing --version x.y.z --build N first`);
else if (!already.has(editable.id)) { plan.appVersion = editable; plan.items.unshift({ kind: 'appStoreVersion', versionId: editable.id, label: `app version ${editable.versionString}` }); }
}
@ -147,6 +180,9 @@ export function planIapSubmission(s: SubmitState, want: string[], o: { withVersi
return plan;
}
/** Whether the run ends with a submit: new items, or a submission back from App Review that only needs resubmitting. Pure. */
export const willSubmit = (s: SubmitState, plan: SubmitPlan): boolean => plan.items.length > 0 || s.draft?.state === 'UNRESOLVED_ISSUES';
/** Fills the placeholders of a planned request (app id, the id of the draft created a moment ago). Pure. */
export function bindIds(r: Req, ids: { app?: string; submission?: string }): Req {
const text = JSON.stringify(r).replaceAll('${app}', ids.app ?? '${app}').replaceAll('${submission}', ids.submission ?? '${submission}');
@ -164,3 +200,44 @@ export function newVersionReq(kind: 'iap' | 'sub', productApiId: string, label:
/** Versions response -> PVersion list. Pure. */
export const versionsOf = (data: { id: string; attributes?: { version?: number; state?: string } }[]): PVersion[] =>
data.map(v => ({ id: v.id, version: v.attributes?.version, state: v.attributes?.state ?? '?' }));
// ------------------------------------------------------------------------------------------------------- resubmit
/** App Store version states that come back from App Review and can be fixed and resubmitted. */
export const REJECTED_APP = new Set(['REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY', 'DEVELOPER_REJECTED']);
export interface BuildStep { number: string; id: string | null; current: string | null; action: 'attach' | 'same' | 'refuse'; reason: string }
export interface PlanRow { step: string; what: string; now: string; action: string }
/** What a version id stands for: app version x.y.z, a product id, a subscription group. Pure. */
export function labelOf(s: SubmitState, versionId: string, kind: string): string {
const av = s.appVersions.find(v => v.id === versionId);
if (av) return `app version ${av.versionString}`;
const p = [...s.iaps, ...s.subs].find(x => x.versions.some(v => v.id === versionId));
if (p) return p.productId;
const g = s.groups.find(x => x.versions.some(v => v.id === versionId));
if (g) return `subscription group "${g.name}"`;
return `${kind} ${versionId}`;
}
/** 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[] {
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' });
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"' });
return rows;
}
/** Rows as aligned text columns. Pure. */
export function formatRows(rows: PlanRow[]): string {
const head: PlanRow = { step: 'STEP', what: 'WHAT', now: 'NOW', action: 'ACTION' };
const all = [head, ...rows];
const w = (k: keyof PlanRow): number => Math.max(...all.map(r => r[k].length));
return all.map(r => `${r.step.padEnd(w('step'))} ${r.what.padEnd(w('what'))} ${r.now.padEnd(w('now'))} ${r.action}`).join('\n');
}

View file

@ -31,6 +31,8 @@ export const isAmbiguous = (e: unknown): boolean => e instanceof AmbiguousWriteE
export function isConflict(e: unknown): boolean {
const s = String((e as Error)?.message ?? e);
if (/ALREADY_EXISTS/.test(s)) return true;
// "already added to another reviewSubmission": the item is held elsewhere, not a duplicate of this create
if (/ITEM_PART_OF_ANOTHER_SUBMISSION/.test(s)) return false;
if (!/(-> |: )409\b/.test(s)) return false;
return /duplicate|already|unique|in use|taken|exists/i.test(s.replace(/^.*?409/, ''));
}