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
|
|
@ -160,6 +160,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'promotedPurchases_createInstance': 'lib/offers',
|
||||
'promotedPurchases_updateInstance': 'lib/offers',
|
||||
'reviewSubmissionItems_createInstance': 'apple/submission lib/cpp-events lib/review-submit',
|
||||
'reviewSubmissionItems_updateInstance': 'apple/submission',
|
||||
'reviewSubmissions_createInstance': 'apple/cpp apple/events apple/submission lib/review-submit',
|
||||
'reviewSubmissions_getCollection': 'apple/events apple/iap-submit apple/status apple/submission',
|
||||
'reviewSubmissions_getInstance': 'apple/submission',
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
// storebound apple status [--json]
|
||||
// And the one write it offers: cancel a review submission (to re-add first IAPs to it from the web UI):
|
||||
// storebound apple status --cancel-submission <id> [--yes]
|
||||
// An empty draft (READY_FOR_REVIEW, 0 items) cannot be canceled (409 "Resource is not in cancellable state"); it is
|
||||
// harmless, shown as such and left as is.
|
||||
// A submission back from App Review (UNRESOLVED_ISSUES) is shown with its items; resubmit it with
|
||||
// storebound apple resubmit --build <n>
|
||||
// Wait for App Review (polls with backoff: 1 min, x1.5, max 15 min):
|
||||
|
|
@ -11,8 +13,9 @@ import type { Ctx } from '../lib/ctx';
|
|||
import { confirm, need } from '../lib/ctx';
|
||||
import { log, printJson } from '../lib/log';
|
||||
import { classifyReviewState, nextPollDelay, parseDuration, WAIT_EXIT } from '../lib/release';
|
||||
import { EMPTY_DRAFT } from '../lib/review-submit';
|
||||
import { type Asc, ascClient, type J } from './asc';
|
||||
import { readItems, type SubItem } from './submission';
|
||||
import { notCancellable, readItems, type SubItem } from './submission';
|
||||
|
||||
async function waitReview(ctx: Ctx, c: Asc, appId: string): Promise<never> {
|
||||
const want = ctx.args.get('version');
|
||||
|
|
@ -47,7 +50,14 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const cancel = ctx.args.get('cancel-submission');
|
||||
if (cancel) {
|
||||
if (!(await confirm(ctx, `Cancel review submission ${cancel}? (the version goes back to "Prepare for Submission")`))) return;
|
||||
await c.asc('PATCH', `/v1/reviewSubmissions/${cancel}`, { data: { type: 'reviewSubmissions', id: cancel, attributes: { canceled: true } } });
|
||||
try {
|
||||
await c.asc('PATCH', `/v1/reviewSubmissions/${cancel}`, { data: { type: 'reviewSubmissions', id: cancel, attributes: { canceled: true } } });
|
||||
} catch (e) {
|
||||
if (!notCancellable(e)) throw e;
|
||||
log('apple', `review submission ${cancel} is not cancellable (App Store Connect answered "Resource is not in cancellable state"); left as is. `
|
||||
+ `An ${EMPTY_DRAFT} does no harm: a later run reuses it`);
|
||||
return;
|
||||
}
|
||||
log('apple', `review submission ${cancel} canceled; it takes a minute to settle`);
|
||||
return;
|
||||
}
|
||||
|
|
@ -60,6 +70,9 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
// a submission back from App Review: its items and their states, and how to resubmit it
|
||||
const unresolved: { id: string; items: SubItem[] }[] = [];
|
||||
for (const s of subs.filter((x: J) => x.attributes?.state === 'UNRESOLVED_ISSUES')) unresolved.push({ id: s.id, items: await readItems(c, s.id) });
|
||||
// an empty draft: App Store Connect keeps it and refuses to cancel it; harmless
|
||||
const empty = new Set<string>();
|
||||
for (const s of subs.filter((x: J) => x.attributes?.state === 'READY_FOR_REVIEW')) if (!(await readItems(c, s.id)).length) empty.add(s.id);
|
||||
const itemLabel = (kind: string, id: string): string => {
|
||||
const v = versions.find((x: J) => x.id === id);
|
||||
return v ? `app version ${v.attributes.versionString}` : `${kind} ${id}`;
|
||||
|
|
@ -83,7 +96,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const v = pre.get(b.relationships?.preReleaseVersion?.data?.id ?? '');
|
||||
if (v) buildVersions[b.id] = v;
|
||||
}
|
||||
printJson({ app, versions, builds: builds.data, attachedBuilds, buildVersions, iaps, subs, phased, unresolved });
|
||||
printJson({ app, versions, builds: builds.data, attachedBuilds, buildVersions, iaps, subs, phased, unresolved, emptyDrafts: [...empty] });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -92,10 +105,10 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const pre = new Map((builds.included ?? []).map((p: { id: string; attributes: { version: string } }) => [p.id, p.attributes.version]));
|
||||
for (const b of builds.data) console.log(` build ${String(pre.get(b.relationships?.preReleaseVersion?.data?.id) ?? '?')} (${b.attributes.version}) ${b.attributes.processingState} ${b.attributes.uploadedDate?.slice(0, 10) ?? ''}`);
|
||||
for (const p of iaps) console.log(` iap ${p.attributes.productId.padEnd(40)} ${p.attributes.inAppPurchaseType} ${p.attributes.state}`);
|
||||
for (const s of subs) console.log(` review submission ${s.id} ${s.attributes.state} ${s.attributes.submittedDate?.slice(0, 10) ?? ''}`);
|
||||
for (const s of subs) console.log(` review submission ${s.id} ${s.attributes.state}${empty.has(s.id) ? `: ${EMPTY_DRAFT}` : ''} ${s.attributes.submittedDate?.slice(0, 10) ?? ''}`.trimEnd());
|
||||
for (const u of unresolved) {
|
||||
console.log(` review submission ${u.id} is back from App Review with UNRESOLVED_ISSUES; its items:`);
|
||||
for (const it of u.items) console.log(` ${itemLabel(it.kind, it.versionId).padEnd(36)} ${it.state}`);
|
||||
for (const it of u.items) console.log(` ${itemLabel(it.kind, it.versionId).padEnd(36)} ${it.state}${it.state === 'REJECTED' ? ' (not resolved: Apple takes no resubmit until it is; apple resubmit marks it resolved)' : ''}`);
|
||||
const newest = builds.data[0]?.attributes?.version;
|
||||
console.log(` resubmit with: storebound apple resubmit --build <n>${newest ? ` (newest uploaded build: ${newest})` : ''}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
// submit. Submitting itself (PATCH submitted: true) stays the owner's confirmed step in those commands.
|
||||
import { log, warn } from '../lib/log';
|
||||
import { createOnce, findOrCreate } from '../lib/upsert';
|
||||
import { EMPTY_DRAFT, resolveHint, type ResolveScope, toResolve } from '../lib/review-submit';
|
||||
import type { Asc, J } from './asc';
|
||||
import { one } from './jsonapi';
|
||||
|
||||
|
|
@ -20,7 +21,7 @@ export async function draftSubmission(c: Asc, appId: string, root: string, tag:
|
|||
/** The journal key of the draft storebound created (cli/lib/upsert.ts). */
|
||||
export const draftJournalKey = (appId: string): string => `apple:${appId}:reviewSubmission:draft`;
|
||||
|
||||
/** Like draftSubmission, and says whether this run created the draft (so it can be canceled again if unused). */
|
||||
/** Like draftSubmission, and says whether this run created the draft (an unused empty one is left: not cancellable). */
|
||||
export async function ensureDraft(c: Asc, appId: string, root: string, tag: string): Promise<{ id: string; created: boolean }> {
|
||||
const r = await findOrCreate<string>({ what: 'draft review submission', tag, unique: true,
|
||||
find: () => findDraft(c, appId),
|
||||
|
|
@ -102,12 +103,35 @@ export class OtherSubmissionError extends Error {
|
|||
constructor(readonly otherId: string, readonly kind: string, readonly itemId: string, message: string) { super(message); this.name = 'OtherSubmissionError'; }
|
||||
}
|
||||
|
||||
/** Cancels a draft this run created and could not use (PATCH canceled: true), only while it is still empty. */
|
||||
export async function cancelUnusedDraft(c: Asc, sid: string, tag: string): Promise<boolean> {
|
||||
if ((await readItems(c, sid)).length) { warn(tag, `draft ${sid} is not empty; left as it is`); return false; }
|
||||
await c.asc('PATCH', `/v1/reviewSubmissions/${sid}`, { data: { type: 'reviewSubmissions', id: sid, attributes: { canceled: true } } });
|
||||
log(tag, `empty draft ${sid} created by this run canceled (not used)`);
|
||||
return true;
|
||||
/** A draft this run created and could not use. Not canceled: App Store Connect answers 409 "Resource is not in
|
||||
* cancellable state" for an empty READY_FOR_REVIEW draft (seen live). It is harmless and a later run reuses it. */
|
||||
export async function leaveUnusedDraft(c: Asc, sid: string, tag: string): Promise<void> {
|
||||
const n = (await readItems(c, sid)).length;
|
||||
if (n) warn(tag, `draft ${sid} (${n} item(s)) is not empty; left as it is`);
|
||||
else log(tag, `draft ${sid} created by this run was not used: ${EMPTY_DRAFT}; a later run reuses it`);
|
||||
}
|
||||
|
||||
/** Apple's 409 when an empty draft is canceled: "Resource is not in cancellable state". Pure. */
|
||||
export const notCancellable = (e: unknown): boolean => /-> 409\b/.test(String((e as Error)?.message ?? e)) && /not in cancellable state/i.test(String((e as Error)?.message ?? e));
|
||||
|
||||
/** Marks one item of a submission back from App Review resolved (seen live: the REJECTED item goes to READY_FOR_REVIEW,
|
||||
* and only then does PATCH submitted: true pass). */
|
||||
export async function resolveItem(c: Asc, itemId: string, label: string, tag: string): Promise<void> {
|
||||
await c.asc('PATCH', `/v1/reviewSubmissionItems/${itemId}`, { data: { type: 'reviewSubmissionItems', id: itemId, attributes: { resolved: true } } });
|
||||
log(tag, `${label}: marked resolved (item ${itemId})`);
|
||||
}
|
||||
|
||||
/** Marks the REJECTED items of `sid` that `scope` covers resolved; returns the ids it marked. */
|
||||
export async function resolveRejected(c: Asc, sid: string, scope: ResolveScope | undefined, tag: string, label: (it: SubItem) => string = it => `${it.kind} ${it.versionId}`): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
for (const it of toResolve(await readItems(c, sid), scope)) { await resolveItem(c, it.id, label(it), tag); out.push(it.id); }
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface SubmitRetryOpts {
|
||||
/** REJECTED items the run may mark resolved when Apple says "not ready" */
|
||||
resolve?: ResolveScope;
|
||||
label?: (it: SubItem) => string;
|
||||
}
|
||||
|
||||
/** Waits of the submit retry (injectable: tests replace sleep and now, nothing waits for real). */
|
||||
|
|
@ -122,11 +146,16 @@ export const submitTiming = {
|
|||
/** Apple's 409 right after a new build is attached: "Version is not ready to be submitted yet, please try again later". Pure. */
|
||||
export const notReadyYet = (e: unknown): boolean => /-> 409\b/.test(String((e as Error)?.message ?? e)) && /not ready to be submitted|try again later/i.test(String((e as Error)?.message ?? e));
|
||||
|
||||
/** PATCH submitted: true, retried with backoff (15 s, 30 s, 60 s, then every 2 min; heartbeat lines while waiting)
|
||||
* while Apple answers "not ready to be submitted yet", up to submitTiming.timeoutMs. Any other error is thrown at once. */
|
||||
export async function submitWithRetry(c: Asc, sid: string, tag: string): Promise<void> {
|
||||
/** PATCH submitted: true. While Apple answers "not ready to be submitted yet" the submission's items are read again
|
||||
* first: a REJECTED item in `o.resolve` is marked resolved and the submit tried again at once; a REJECTED item out of
|
||||
* scope stops the run with the item and how to resolve it (seen live: retrying never passes while one is left). Only
|
||||
* when nothing is unresolved is the submit retried with backoff (15 s, 30 s, 60 s, then every 2 min; heartbeat lines),
|
||||
* up to submitTiming.timeoutMs. Any other error is thrown at once. */
|
||||
export async function submitWithRetry(c: Asc, sid: string, tag: string, o: SubmitRetryOpts = {}): Promise<void> {
|
||||
const t = submitTiming;
|
||||
const start = t.now();
|
||||
const label = o.label ?? ((it: SubItem) => `${it.kind} ${it.versionId}`);
|
||||
const tried = new Set<string>();
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
await c.asc('PATCH', `/v1/reviewSubmissions/${sid}`, { data: { type: 'reviewSubmissions', id: sid, attributes: { submitted: true } } });
|
||||
|
|
@ -134,14 +163,25 @@ export async function submitWithRetry(c: Asc, sid: string, tag: string): Promise
|
|||
return;
|
||||
} catch (e) {
|
||||
if (!notReadyYet(e)) throw e;
|
||||
const rejected = (await readItems(c, sid)).filter(i => i.state === 'REJECTED');
|
||||
const mine = toResolve(rejected, o.resolve).filter(i => !tried.has(i.id));
|
||||
if (mine.length) {
|
||||
for (const it of mine) { tried.add(it.id); await resolveItem(c, it.id, label(it), tag); }
|
||||
continue;
|
||||
}
|
||||
if (rejected.length) {
|
||||
throw new Error(`review submission ${sid}: Apple answers "not ready to be submitted yet" because ${rejected.length === 1 ? 'an item is' : `${rejected.length} items are`} still REJECTED `
|
||||
+ `and not marked resolved; retrying would never pass, so nothing more is sent:\n${rejected.map(i => ` ${label(i)} (item ${i.id}): ${resolveHint(i.kind)}`).join('\n')}\n`
|
||||
+ `Last answer: ${(e as Error).message}`);
|
||||
}
|
||||
const elapsed = t.now() - start;
|
||||
const delay = t.delaysMs[Math.min(attempt - 1, t.delaysMs.length - 1)]!;
|
||||
if (elapsed + delay > t.timeoutMs) {
|
||||
throw new Error(`review submission ${sid}: Apple still answers "not ready to be submitted yet" after ${Math.round(elapsed / 1000)} s and ${attempt} tries `
|
||||
+ `(a build attached a moment ago is still being prepared for review). Nothing else changed: re-run the same command in a few minutes; it reuses submission ${sid}.\n`
|
||||
+ `(a build attached a moment ago is still being prepared for review; no item is left REJECTED). Nothing else changed: re-run the same command in a few minutes; it reuses submission ${sid}.\n`
|
||||
+ `Last answer: ${(e as Error).message}`);
|
||||
}
|
||||
log(tag, `review submission ${sid}: not ready to be submitted yet (try ${attempt}, ${Math.round(elapsed / 1000)} s so far, gives up after ${Math.round(t.timeoutMs / 60_000)} min); next try in ${Math.round(delay / 1000)} s`);
|
||||
log(tag, `review submission ${sid}: not ready to be submitted yet, no item left REJECTED (try ${attempt}, ${Math.round(elapsed / 1000)} s so far, gives up after ${Math.round(t.timeoutMs / 60_000)} min); next try in ${Math.round(delay / 1000)} s`);
|
||||
for (let left = delay; left > 0;) {
|
||||
const step = Math.min(left, t.heartbeatMs);
|
||||
await t.sleep(step);
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@
|
|||
"apple iap-submit": [
|
||||
"new-version",
|
||||
"no-submit",
|
||||
"resolve-iaps",
|
||||
"with-version"
|
||||
],
|
||||
"apple listing": [
|
||||
|
|
@ -155,6 +156,7 @@
|
|||
"build",
|
||||
"new-version",
|
||||
"no-submit",
|
||||
"resolve-iaps",
|
||||
"version",
|
||||
"with-version"
|
||||
],
|
||||
|
|
@ -826,6 +828,7 @@
|
|||
"release-date",
|
||||
"remove",
|
||||
"render",
|
||||
"resolve-iaps",
|
||||
"resume",
|
||||
"scenes",
|
||||
"scheme",
|
||||
|
|
@ -945,7 +948,7 @@
|
|||
"storebound apple iap-review [--dry]"
|
||||
],
|
||||
"apple iap-submit": [
|
||||
"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]"
|
||||
],
|
||||
"apple listing": [
|
||||
"storebound apple listing --version 1.0.0 [--build 3] [--include-drafts] [--dry] [--json]",
|
||||
|
|
@ -979,7 +982,7 @@
|
|||
"storebound apple release --manual-release [--version 1.2.0] [--yes]"
|
||||
],
|
||||
"apple resubmit": [
|
||||
"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]"
|
||||
],
|
||||
"apple review-details": [
|
||||
"storebound apple review-details [--contact-from <Apple ID of another app>] [--dry]"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue