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,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}`);
}
}