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

@ -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;
}