apple resubmit after a rejection: iap-submit reuses the open review submission (UNRESOLVED_ISSUES gets only missing items and is resubmitted, a draft is reused, one waiting for or in review means nothing is sent), never a second draft; 409 ITEM_PART_OF_ANOTHER_SUBMISSION names the other submission instead of the list-lag message, a draft the run created is canceled and the named one used; submit retried with backoff and heartbeat while Apple says not ready yet; new apple resubmit --build <n> (build by number, plan table, --yes); apple status lists UNRESOLVED_ISSUES items and the resubmit command. Pitfall 30
This commit is contained in:
parent
c8213be2b6
commit
0da5ee9d01
21 changed files with 726 additions and 80 deletions
|
|
@ -65,7 +65,7 @@ const READ: string[][] = [
|
|||
const WAIT: string[][] = [['apple', 'testflight', 'wait']];
|
||||
/** Never executed by a flow: the owner does these (AGENTS.md rule 1). */
|
||||
const OWNER: string[][] = [
|
||||
['apple', 'release'], ['play', 'rollout'], ['reviews', 'send'], ['prices', 'import'], ['apple', 'iap-submit'], ['apple', 'ppo', 'start'], ['apple', 'ppo', 'stop'],
|
||||
['apple', 'release'], ['play', 'rollout'], ['reviews', 'send'], ['prices', 'import'], ['apple', 'iap-submit'], ['apple', 'resubmit'], ['apple', 'ppo', 'start'], ['apple', 'ppo', 'stop'],
|
||||
['apple', 'cpp', 'submit'], ['apple', 'cpp', 'visible'], ['apple', 'events', 'submit'], ['apple', 'preorder', 'enable'], ['apple', 'preorder', 'end'],
|
||||
['apple', 'testflight', 'testers'], ['apple', 'testflight', 'expire'], ['apple', 'offer-codes'], ['apple', 'win-back'], ['ads', 'apple', 'bid'],
|
||||
['ads', 'apple', 'budget'], ['ads', 'apple', 'pause'], ['apple', 'adhoc', 'register'],
|
||||
|
|
|
|||
|
|
@ -53,8 +53,39 @@ export interface SubmitState {
|
|||
iaps: PIap[];
|
||||
subs: PSub[];
|
||||
groups: PGroup[];
|
||||
/** The app's draft review submission (state READY_FOR_REVIEW), if one exists, and the version ids already in it. */
|
||||
draft?: { id: string; itemVersionIds: string[] } | null;
|
||||
/** The submission this run uses, if one exists, and the version ids already in it: a draft (READY_FOR_REVIEW), or
|
||||
* one back from App Review (UNRESOLVED_ISSUES), which is resubmitted, never replaced by a second one. */
|
||||
draft?: { id: string; itemVersionIds: string[]; state?: string } | null;
|
||||
/** Every open review submission (chooseSubmission) and what was decided. */
|
||||
open?: OpenSub[];
|
||||
choice?: SubmissionChoice;
|
||||
}
|
||||
|
||||
export interface OpenSub { id: string; state: string; items: { kind: string; versionId: string; state: string }[] }
|
||||
export type SubmissionChoice =
|
||||
| { action: 'reuse'; sub: OpenSub; why: string; strayDrafts: OpenSub[] }
|
||||
| { action: 'busy'; sub: OpenSub; why: string }
|
||||
| { action: 'create'; why: string };
|
||||
|
||||
/**
|
||||
* Which review submission to use, from the app's open ones (READY_FOR_REVIEW, UNRESOLVED_ISSUES, WAITING_FOR_REVIEW,
|
||||
* IN_REVIEW). Never a second one while one of them exists (seen live: a new empty draft next to a rejected
|
||||
* submission, then 409 ITEM_PART_OF_ANOTHER_SUBMISSION for the app version):
|
||||
* - one waiting for or in review: nothing can be submitted until App Review is done (busy);
|
||||
* - one with UNRESOLVED_ISSUES (rejected, preferably the one holding `appVersionId`): reused and resubmitted, with
|
||||
* only the missing items added; empty drafts next to it are strays;
|
||||
* - a draft (READY_FOR_REVIEW): reused;
|
||||
* - none: a new draft. Pure.
|
||||
*/
|
||||
export function chooseSubmission(open: OpenSub[], o: { appVersionId?: string } = {}): SubmissionChoice {
|
||||
const busy = open.find(x => IN_REVIEW.has(x.state));
|
||||
if (busy) return { action: 'busy', sub: busy, why: `review submission ${busy.id} is ${busy.state}: nothing more can be submitted until App Review finishes it (storebound apple status --wait-review)` };
|
||||
const unresolved = open.filter(x => x.state === 'UNRESOLVED_ISSUES');
|
||||
const holder = unresolved.find(x => o.appVersionId && x.items.some(i => i.versionId === o.appVersionId)) ?? unresolved[0];
|
||||
const drafts = open.filter(x => x.state === 'READY_FOR_REVIEW');
|
||||
if (holder) return { action: 'reuse', sub: holder, strayDrafts: drafts, why: `review submission ${holder.id} has UNRESOLVED_ISSUES (back from App Review)${holder.items.some(i => i.versionId === o.appVersionId) ? ' and holds the app version' : ''}: reused and resubmitted, no new draft` };
|
||||
if (drafts[0]) return { action: 'reuse', sub: drafts[0], strayDrafts: drafts.slice(1), why: `draft review submission ${drafts[0].id} reused` };
|
||||
return { action: 'create', why: 'no open review submission: a new draft' };
|
||||
}
|
||||
|
||||
export interface PlanItem { kind: ItemKind; versionId: string; label: string }
|
||||
|
|
@ -88,7 +119,7 @@ export function latestVersion(vs: PVersion[]): PVersion | undefined {
|
|||
* Plan for submitting `want` (product ids of in-app purchases and subscriptions). withVersion: put the editable app
|
||||
* version into the same submission (needed for the first item of a type, or while the app has no approved version).
|
||||
*/
|
||||
export function planIapSubmission(s: SubmitState, want: string[], o: { withVersion?: boolean } = {}): SubmitPlan {
|
||||
export function planIapSubmission(s: SubmitState, want: string[], o: { withVersion?: boolean; appVersionId?: string } = {}): SubmitPlan {
|
||||
const plan: SubmitPlan = { items: [], skipped: [], firstOfType: [], hasApprovedVersion: s.appVersions.some(v => APPROVED_APP.has(v.state)), needsAppVersion: false, blockers: [], prepare: [],
|
||||
submit: { method: 'PATCH', path: '/v1/reviewSubmissions/${submission}', note: 'submit for App Review', body: {} } };
|
||||
const approvedTypes = new Set([...s.iaps.filter(p => APPROVED_PRODUCT.has(p.state)).map(p => p.type), ...(s.subs.some(x => APPROVED_PRODUCT.has(x.state)) ? ['AUTO_RENEWABLE'] : [])]);
|
||||
|
|
@ -129,12 +160,14 @@ export function planIapSubmission(s: SubmitState, want: string[], o: { withVersi
|
|||
plan.items.push({ kind: 'subscriptionGroupVersion', versionId: v.id, label: `subscription group "${g.name}" (not approved yet, goes with its subscriptions)` });
|
||||
}
|
||||
plan.firstOfType = [...firstTypes].map(typeLabel);
|
||||
plan.needsAppVersion = plan.items.length > 0 && (firstTypes.size > 0 || !plan.hasApprovedVersion);
|
||||
const forced = o.appVersionId ? s.appVersions.find(v => v.id === o.appVersionId) : undefined;
|
||||
plan.needsAppVersion = !!forced || (plan.items.length > 0 && (firstTypes.size > 0 || !plan.hasApprovedVersion));
|
||||
if (plan.needsAppVersion) {
|
||||
const editable = s.appVersions.find(v => ['PREPARE_FOR_SUBMISSION', 'DEVELOPER_REJECTED', 'REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY', 'READY_FOR_REVIEW'].includes(v.state));
|
||||
// resubmit: the version named by the caller; else the editable one
|
||||
const editable = forced ?? s.appVersions.find(v => ['PREPARE_FOR_SUBMISSION', 'DEVELOPER_REJECTED', 'REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY', 'READY_FOR_REVIEW'].includes(v.state));
|
||||
const why = firstTypes.size ? `first ${plan.firstOfType.join(' / ')} of this app: Apple reviews it only together with a new app version`
|
||||
: 'the app has no approved version yet: in-app purchases go together with an app version';
|
||||
if (!o.withVersion) plan.blockers.push(`${why}. Add --with-version (the editable version ${editable ? editable.versionString : '(none yet: storebound apple listing --version x.y.z --build N)'} goes into the same submission; submitting it submits the app)`);
|
||||
if (!o.withVersion && !forced) plan.blockers.push(`${why}. Add --with-version (the editable version ${editable ? editable.versionString : '(none yet: storebound apple listing --version x.y.z --build N)'} goes into the same submission; submitting it submits the app)`);
|
||||
else if (!editable) plan.blockers.push(`${why}, and there is no editable app version: storebound apple listing --version x.y.z --build N first`);
|
||||
else if (!already.has(editable.id)) { plan.appVersion = editable; plan.items.unshift({ kind: 'appStoreVersion', versionId: editable.id, label: `app version ${editable.versionString}` }); }
|
||||
}
|
||||
|
|
@ -147,6 +180,9 @@ export function planIapSubmission(s: SubmitState, want: string[], o: { withVersi
|
|||
return plan;
|
||||
}
|
||||
|
||||
/** Whether the run ends with a submit: new items, or a submission back from App Review that only needs resubmitting. Pure. */
|
||||
export const willSubmit = (s: SubmitState, plan: SubmitPlan): boolean => plan.items.length > 0 || s.draft?.state === 'UNRESOLVED_ISSUES';
|
||||
|
||||
/** Fills the placeholders of a planned request (app id, the id of the draft created a moment ago). Pure. */
|
||||
export function bindIds(r: Req, ids: { app?: string; submission?: string }): Req {
|
||||
const text = JSON.stringify(r).replaceAll('${app}', ids.app ?? '${app}').replaceAll('${submission}', ids.submission ?? '${submission}');
|
||||
|
|
@ -164,3 +200,44 @@ export function newVersionReq(kind: 'iap' | 'sub', productApiId: string, label:
|
|||
/** Versions response -> PVersion list. Pure. */
|
||||
export const versionsOf = (data: { id: string; attributes?: { version?: number; state?: string } }[]): PVersion[] =>
|
||||
data.map(v => ({ id: v.id, version: v.attributes?.version, state: v.attributes?.state ?? '?' }));
|
||||
|
||||
// ------------------------------------------------------------------------------------------------------- resubmit
|
||||
|
||||
/** App Store version states that come back from App Review and can be fixed and resubmitted. */
|
||||
export const REJECTED_APP = new Set(['REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY', 'DEVELOPER_REJECTED']);
|
||||
|
||||
export interface BuildStep { number: string; id: string | null; current: string | null; action: 'attach' | 'same' | 'refuse'; reason: string }
|
||||
export interface PlanRow { step: string; what: string; now: string; action: string }
|
||||
|
||||
/** What a version id stands for: app version x.y.z, a product id, a subscription group. Pure. */
|
||||
export function labelOf(s: SubmitState, versionId: string, kind: string): string {
|
||||
const av = s.appVersions.find(v => v.id === versionId);
|
||||
if (av) return `app version ${av.versionString}`;
|
||||
const p = [...s.iaps, ...s.subs].find(x => x.versions.some(v => v.id === versionId));
|
||||
if (p) return p.productId;
|
||||
const g = s.groups.find(x => x.versions.some(v => v.id === versionId));
|
||||
if (g) return `subscription group "${g.name}"`;
|
||||
return `${kind} ${versionId}`;
|
||||
}
|
||||
|
||||
/** 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[] {
|
||||
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' });
|
||||
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"' });
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Rows as aligned text columns. Pure. */
|
||||
export function formatRows(rows: PlanRow[]): string {
|
||||
const head: PlanRow = { step: 'STEP', what: 'WHAT', now: 'NOW', action: 'ACTION' };
|
||||
const all = [head, ...rows];
|
||||
const w = (k: keyof PlanRow): number => Math.max(...all.map(r => r[k].length));
|
||||
return all.map(r => `${r.step.padEnd(w('step'))} ${r.what.padEnd(w('what'))} ${r.now.padEnd(w('now'))} ${r.action}`).join('\n');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ export const isAmbiguous = (e: unknown): boolean => e instanceof AmbiguousWriteE
|
|||
export function isConflict(e: unknown): boolean {
|
||||
const s = String((e as Error)?.message ?? e);
|
||||
if (/ALREADY_EXISTS/.test(s)) return true;
|
||||
// "already added to another reviewSubmission": the item is held elsewhere, not a duplicate of this create
|
||||
if (/ITEM_PART_OF_ANOTHER_SUBMISSION/.test(s)) return false;
|
||||
if (!/(-> |: )409\b/.test(s)) return false;
|
||||
return /duplicate|already|unique|in use|taken|exists/i.test(s.replace(/^.*?409/, ''));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue