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

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