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:
parent
0da5ee9d01
commit
1724296e59
17 changed files with 291 additions and 78 deletions
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue