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
|
|
@ -6,19 +6,23 @@
|
|||
// --new-version: approved or rejected products get a new draft version first (POST inAppPurchaseVersions /
|
||||
// subscriptionVersions) so changed metadata can go to review. --no-submit: only fill the draft submission; the owner
|
||||
// presses "Submit for Review" in App Store Connect. Without --no-submit the last step asks first (never under MCP).
|
||||
// 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).
|
||||
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, latestVersion, newVersionReq, type PIap, planIapSubmission, type PSub, type SubmitState, versionsOf } from '../lib/review-submit';
|
||||
import { bindIds, chooseSubmission, 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, draftSubmission } from './submission';
|
||||
import { addItem, cancelUnusedDraft, draftJournalKey, ensureDraft, openSubmissions, OtherSubmissionError, submitWithRetry } from './submission';
|
||||
|
||||
const TAG = 'iap-submit';
|
||||
|
||||
export async function readSubmitState(c: Asc, appId: string, want: string[]): Promise<SubmitState> {
|
||||
export async function readSubmitState(c: Asc, appId: string, want: string[], o: { appVersionId?: string; also?: string } = {}): Promise<SubmitState> {
|
||||
const appVersions = (await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`)).map((v: J) => ({ id: v.id, versionString: v.attributes.versionString,
|
||||
state: v.attributes.appVersionState ?? v.attributes.appStoreState }));
|
||||
const iaps: PIap[] = [];
|
||||
|
|
@ -36,19 +40,17 @@ export async function readSubmitState(c: Asc, appId: string, want: string[]): Pr
|
|||
for (const s of members) subs.push({ id: s.id, productId: s.attributes.productId, groupId: g.id, state: s.attributes.state,
|
||||
versions: want.includes(s.attributes.productId) ? versionsOf(await c.all(`/v1/subscriptions/${s.id}/versions?limit=200`)) : [] });
|
||||
}
|
||||
const drafts = await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&filter[platform]=IOS&filter[state]=READY_FOR_REVIEW&limit=10`);
|
||||
let draft: SubmitState['draft'] = null;
|
||||
if (drafts[0]) {
|
||||
const items = await c.all(`/v1/reviewSubmissions/${drafts[0].id}/items?include=appStoreVersion,inAppPurchaseVersion,subscriptionVersion,subscriptionGroupVersion&limit=200`);
|
||||
const ids = items.flatMap((it: J) => Object.values(it.relationships ?? {}).map((r: J) => r?.data?.id).filter(Boolean)) as string[];
|
||||
draft = { id: drafts[0].id, itemVersionIds: ids };
|
||||
}
|
||||
return { appVersions, iaps, subs, groups, draft };
|
||||
// every open review submission: a draft or one back from App Review is reused, never a second one next to it
|
||||
const open = await openSubmissions(c, appId, o.also);
|
||||
const choice = chooseSubmission(open, { appVersionId: o.appVersionId ?? appVersions.find((v: { state: string }) => /REJECTED|INVALID_BINARY/.test(v.state))?.id });
|
||||
const draft: SubmitState['draft'] = choice.action === 'reuse' ? { id: choice.sub.id, state: choice.sub.state, itemVersionIds: choice.sub.items.map(i => i.versionId) } : null;
|
||||
return { appVersions, iaps, subs, groups, draft, open, choice };
|
||||
}
|
||||
|
||||
/** The requests a run would send: none when nothing is left to submit (seen live: every product already in review). */
|
||||
export function planRequests<R>(prepare: R[], plan: { items: unknown[]; submit: R }): R[] {
|
||||
return plan.items.length ? [...prepare, plan.submit] : [];
|
||||
/** The requests a run would send: none when nothing is left to submit (seen live: every product already in review),
|
||||
* only the submit when a submission back from App Review is resubmitted as it is. */
|
||||
export function planRequests<R>(prepare: R[], plan: { items: unknown[]; submit: R }, resubmit = false): R[] {
|
||||
return plan.items.length || resubmit ? [...prepare, plan.submit] : [];
|
||||
}
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
|
|
@ -74,46 +76,112 @@ 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') });
|
||||
}
|
||||
|
||||
const plan = planIapSubmission(state, ids, { withVersion: a.has('with-version') });
|
||||
const prepare = plan.prepare.map(r => bindIds(r, { app: appId }));
|
||||
if (a.has('json')) {
|
||||
/** Lines that say which submission is used and what else is open next to it. */
|
||||
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'}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface SubmitOpts {
|
||||
appId: string; ids: string[]; state: SubmitState; tag: string; withVersion?: boolean;
|
||||
/** the app version that must be in the submission (resubmit) */
|
||||
appVersionId?: string;
|
||||
/** the caller printed its own plan (resubmit) */
|
||||
quiet?: boolean;
|
||||
/** second pass after a 409 named another submission */
|
||||
retried?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function submitPlanned(ctx: Ctx, c: Asc, o: SubmitOpts): Promise<void> {
|
||||
const { root } = ctx.loaded;
|
||||
const a = ctx.args;
|
||||
const dry = a.has('dry');
|
||||
const { state, tag } = o;
|
||||
const plan = planIapSubmission(state, o.ids, { withVersion: o.withVersion, appVersionId: o.appVersionId });
|
||||
const prepare = plan.prepare.map(r => bindIds(r, { app: o.appId }));
|
||||
const submitting = willSubmit(state, plan);
|
||||
const busy = state.choice?.action === 'busy';
|
||||
if (a.has('json') && !o.quiet) {
|
||||
printJson({ dryRun: dry, items: plan.items, skipped: plan.skipped, firstOfType: plan.firstOfType, hasApprovedVersion: plan.hasApprovedVersion,
|
||||
needsAppVersion: plan.needsAppVersion, blockers: plan.blockers, draft: state.draft?.id ?? null, requests: planRequests(prepare, plan).map(r => describeReq(r)) });
|
||||
if (dry || plan.blockers.length) { if (plan.blockers.length) process.exitCode = 1; return; }
|
||||
} else {
|
||||
for (const it of plan.items) log(TAG, `submit: ${it.label}`);
|
||||
for (const s of plan.skipped) log(TAG, `skip ${s.id}: ${s.why}`);
|
||||
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);
|
||||
needsAppVersion: plan.needsAppVersion, blockers: plan.blockers, draft: state.draft?.id ?? null, submission: state.choice ?? null,
|
||||
requests: busy ? [] : planRequests(prepare, plan, submitting).map(r => describeReq(r)) });
|
||||
if (dry || plan.blockers.length || busy) { if (plan.blockers.length) process.exitCode = 1; return; }
|
||||
} else if (!o.quiet) {
|
||||
for (const l of choiceLines(state)) log(tag, l);
|
||||
for (const it of plan.items) log(tag, `submit: ${it.label}`);
|
||||
for (const sk of plan.skipped) log(tag, `skip ${sk.id}: ${sk.why}`);
|
||||
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 (plan.blockers.length) { process.exitCode = 1; done(TAG, 'nothing sent: fix the blockers above'); return; }
|
||||
if (!plan.items.length) { done(TAG, 'nothing to submit'); return; }
|
||||
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; }
|
||||
const resubmit = state.draft?.state === 'UNRESOLVED_ISSUES';
|
||||
const target = state.draft ? `${resubmit ? 'review submission' : 'draft'} ${state.draft.id}` : 'a new draft submission';
|
||||
if (dry) {
|
||||
await execute(c.asc, planRequests(prepare, plan), { dry, tag: TAG });
|
||||
done(TAG, `dry run: ${plan.items.length} item(s) would go into ${state.draft ? `draft ${state.draft.id}` : 'a new draft submission'}`);
|
||||
await execute(c.asc, planRequests(prepare, plan, submitting), { dry, tag });
|
||||
done(tag, plan.items.length ? `dry run: ${plan.items.length} item(s) would go into ${target}, then it is submitted` : `dry run: ${target} would be resubmitted as it is`);
|
||||
return;
|
||||
}
|
||||
const what = `${plan.items.length} item(s)${plan.appVersion ? `, including app version ${plan.appVersion.versionString}` : ''}`;
|
||||
if (!(await confirm(ctx, `Add ${what} to ${state.draft ? 'the draft' : 'a new draft'} review submission?`))) return;
|
||||
// the draft is reused (or created once); the plan left out items already in it (readSubmitState read them), and an
|
||||
// item the store says is in there already (409) is not added twice (cli/apple/submission.ts)
|
||||
const sid = state.draft?.id ?? await draftSubmission(c, appId, root, TAG);
|
||||
for (const r of prepare) {
|
||||
if (r.path === '/v1/reviewSubmissions') continue;
|
||||
if (r.path === '/v1/reviewSubmissionItems') {
|
||||
const rels = (r.body as J).data.relationships as Record<string, { data: { id: string } }>;
|
||||
const kind = Object.keys(rels).find(k => k !== 'reviewSubmission')!;
|
||||
if (await addItem(c, sid, kind, rels[kind]!.data.id, r.note ?? kind, TAG, { checked: true })) log(TAG, `${describeReq(bindIds(r, { submission: sid }))}: done`);
|
||||
continue;
|
||||
if (plan.items.length && !(await confirm(ctx, `Add ${what} to ${target}?`))) return;
|
||||
// the open submission is reused, or a draft is created once, only now that none is open; the plan left out items
|
||||
// already in it, and an item the store says is in there already (409) is not added twice
|
||||
let sid = state.draft?.id;
|
||||
let created = false;
|
||||
if (!sid) ({ id: sid, created } = await ensureDraft(c, o.appId, root, tag));
|
||||
try {
|
||||
for (const r of prepare) {
|
||||
if (r.path === '/v1/reviewSubmissions') continue;
|
||||
if (r.path === '/v1/reviewSubmissionItems') {
|
||||
const rels = (r.body as J).data.relationships as Record<string, { data: { id: string } }>;
|
||||
const kind = Object.keys(rels).find(k => k !== 'reviewSubmission')!;
|
||||
if (await addItem(c, sid, kind, rels[kind]!.data.id, r.note ?? kind, tag, { checked: true })) log(tag, `${describeReq(bindIds(r, { submission: sid }))}: done`);
|
||||
continue;
|
||||
}
|
||||
await c.asc(r.method, r.path, bindIds(r, { submission: sid }).body);
|
||||
log(tag, `${describeReq(bindIds(r, { submission: sid }))}: done`);
|
||||
}
|
||||
await c.asc(r.method, r.path, bindIds(r, { submission: sid }).body);
|
||||
log(TAG, `${describeReq(bindIds(r, { submission: sid }))}: done`);
|
||||
} catch (e) {
|
||||
if (!(e instanceof OtherSubmissionError)) throw e;
|
||||
warn(tag, e.message.split('\n')[0]!);
|
||||
if (created) await cancelUnusedDraft(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`);
|
||||
const again = await readSubmitState(c, o.appId, o.ids, { appVersionId: o.appVersionId ?? plan.appVersion?.id, also: e.otherId });
|
||||
for (const l of choiceLines(again)) log(tag, l);
|
||||
await submitPlanned(ctx, c, { ...o, state: again, retried: true, quiet: true });
|
||||
return;
|
||||
}
|
||||
if (a.has('no-submit')) { done(TAG, `draft review submission ${sid} ready: press "Submit for Review" in App Store Connect (Apps > Distribution > App Review)`); return; }
|
||||
if (!(await confirm(ctx, `Submit review submission ${sid} (${what}) to App Review now?${plan.appVersion ? ' This submits the app version too.' : ''}`))) {
|
||||
done(TAG, `draft ${sid} kept; submit it in App Store Connect or re-run`); return;
|
||||
await cleanStrayDrafts(c, o.appId, root, state, tag);
|
||||
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 (!(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);
|
||||
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> {
|
||||
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; }
|
||||
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}`);
|
||||
}
|
||||
await c.asc('PATCH', `/v1/reviewSubmissions/${sid}`, bindIds(plan.submit, { submission: sid }).body);
|
||||
done(TAG, `review submission ${sid} submitted (storebound apple status shows its state)`);
|
||||
}
|
||||
|
|
|
|||
80
cli/apple/resubmit.ts
Normal file
80
cli/apple/resubmit.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// 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]
|
||||
// 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.
|
||||
// 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 { allSubscriptions } from '../lib/subscriptions';
|
||||
import { ascClient, type J } from './asc';
|
||||
import { readSubmitState, submitPlanned } from './iap-submit';
|
||||
import { openSubmissions } from './submission';
|
||||
|
||||
const TAG = 'resubmit';
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const a = ctx.args;
|
||||
const number = a.get('build');
|
||||
if (!number || !/^\d+(\.\d+)*$/.test(number)) throw new Error('usage: apple resubmit --build <n> [--version x.y.z] [--dry] [--yes]: the build number you just uploaded (never "the latest")');
|
||||
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||
const appId = await c.app();
|
||||
const ids = [...(config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id), ...allSubscriptions(config.subscriptions).filter(x => sellsOn(config, x.sub, 'apple')).map(x => x.sub.id)];
|
||||
|
||||
// the version to resubmit
|
||||
const versions = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`);
|
||||
const stateOf = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState;
|
||||
const open = await openSubmissions(c, appId);
|
||||
const held = new Set(open.filter(s => s.state === 'UNRESOLVED_ISSUES').flatMap(s => s.items.filter(i => i.kind === 'appStoreVersion').map(i => i.versionId)));
|
||||
const want = a.get('version');
|
||||
const v: J | undefined = want ? versions.find((x: J) => x.attributes.versionString === want)
|
||||
: versions.find((x: J) => held.has(x.id)) ?? versions.find((x: J) => REJECTED_APP.has(stateOf(x)));
|
||||
if (!v) throw new Error(want ? `no App Store version ${want} on iOS` : 'no rejected App Store version and no review submission with UNRESOLVED_ISSUES: nothing to resubmit (storebound apple status)');
|
||||
const versionString = String(v.attributes.versionString);
|
||||
if (!a.has('json')) log(TAG, `app version ${versionString}: ${stateOf(v)}`);
|
||||
|
||||
// the build, by its number
|
||||
const b = (await c.all(`/v1/builds?filter[app]=${appId}&filter[version]=${number}&filter[preReleaseVersion.version]=${versionString}&filter[preReleaseVersion.platform]=IOS`))[0];
|
||||
const cur = (await c.maybe(`/v1/appStoreVersions/${v.id}/build`))?.data;
|
||||
const current = cur ? String(cur.attributes?.version ?? cur.id) : null;
|
||||
const build: BuildStep = !b ? { number, id: null, current, action: 'refuse', reason: `build ${versionString} (${number}) not found (still uploading or processing? storebound apple status)` }
|
||||
: b.attributes.processingState !== 'VALID' ? { number, id: b.id, current, action: 'refuse', reason: `build ${number} is ${b.attributes.processingState}; wait until VALID` }
|
||||
: cur?.id === b.id ? { number, id: b.id, current, action: 'same', reason: `build ${number} is attached already` }
|
||||
: { number, id: b.id, current, action: 'attach', reason: `attach build ${number}${current ? ` instead of ${current}` : ''}` };
|
||||
if (b && b.attributes.usesNonExemptEncryption == null) warn(TAG, `build ${number} has no export compliance answer yet: storebound apple encryption (Apple refuses the submission without it)`);
|
||||
|
||||
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);
|
||||
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 });
|
||||
if (blockers.length) process.exitCode = 1;
|
||||
if (dry || blockers.length || state.choice?.action === 'busy') return;
|
||||
} else {
|
||||
console.log(formatRows(rows));
|
||||
for (const s of plan.skipped) log(TAG, `not added ${s.id}: ${s.why}`);
|
||||
for (const x of blockers) warn(TAG, x);
|
||||
}
|
||||
if (blockers.length) { process.exitCode = 1; done(TAG, 'nothing sent: fix the blockers above'); return; }
|
||||
if (state.choice?.action === 'busy') { done(TAG, `nothing to resubmit: ${state.choice.why}`); return; }
|
||||
if (dry) {
|
||||
done(TAG, a.has('dry') ? 'dry run: nothing sent' : 'dry run (no --yes): nothing sent. Resubmitting is the owner\'s step: after the owner\'s yes, run it again with --yes');
|
||||
return;
|
||||
}
|
||||
|
||||
if (build.action === 'attach') {
|
||||
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 });
|
||||
}
|
||||
|
|
@ -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]
|
||||
// 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):
|
||||
// storebound apple status --wait-review [--version 1.2.0] [--timeout 48h]
|
||||
// exit codes: 0 approved, 1 rejected, 7 timeout (2 = nothing in review)
|
||||
|
|
@ -10,6 +12,7 @@ import { confirm, need } from '../lib/ctx';
|
|||
import { log, printJson } from '../lib/log';
|
||||
import { classifyReviewState, nextPollDelay, parseDuration, WAIT_EXIT } from '../lib/release';
|
||||
import { type Asc, ascClient, type J } from './asc';
|
||||
import { readItems, type SubItem } from './submission';
|
||||
|
||||
async function waitReview(ctx: Ctx, c: Asc, appId: string): Promise<never> {
|
||||
const want = ctx.args.get('version');
|
||||
|
|
@ -54,6 +57,13 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const builds = (await c.asc('GET', `/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=5&include=preReleaseVersion`));
|
||||
const iaps = await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`);
|
||||
const subs = await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&limit=20`);
|
||||
// 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) });
|
||||
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}`;
|
||||
};
|
||||
if (ctx.args.has('json')) {
|
||||
// phased release of the newest versions that are live or close to it (rollout share on the desktop timeline)
|
||||
const phased: Record<string, unknown> = {};
|
||||
|
|
@ -73,7 +83,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 });
|
||||
printJson({ app, versions, builds: builds.data, attachedBuilds, buildVersions, iaps, subs, phased, unresolved });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -83,4 +93,10 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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 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}`);
|
||||
const newest = builds.data[0]?.attributes?.version;
|
||||
console.log(` resubmit with: storebound apple resubmit --build <n>${newest ? ` (newest uploaded build: ${newest})` : ''}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// reused, a new one is created only after a fresh look (Apple keeps one open draft per app and platform), and an item
|
||||
// already in the draft is not added again (a 409 or a re-read finds it). Used by iap-submit, cpp submit and events
|
||||
// submit. Submitting itself (PATCH submitted: true) stays the owner's confirmed step in those commands.
|
||||
import { log } from '../lib/log';
|
||||
import { log, warn } from '../lib/log';
|
||||
import { createOnce, findOrCreate } from '../lib/upsert';
|
||||
import type { Asc, J } from './asc';
|
||||
import { one } from './jsonapi';
|
||||
|
|
@ -14,13 +14,21 @@ export async function findDraft(c: Asc, appId: string): Promise<string | null> {
|
|||
|
||||
/** The draft's id: the existing one, else a new draft created once. */
|
||||
export async function draftSubmission(c: Asc, appId: string, root: string, tag: string): Promise<string> {
|
||||
return (await ensureDraft(c, appId, root, tag)).id;
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
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),
|
||||
journal: { root, key: `apple:${appId}:reviewSubmission:draft`, idOf: id => id,
|
||||
journal: { root, key: draftJournalKey(appId), idOf: id => id,
|
||||
byId: async id => { const d = (await c.maybe(`/v1/reviewSubmissions/${id}`))?.data; return d?.attributes?.state === 'READY_FOR_REVIEW' ? id : null; } },
|
||||
create: async () => (await c.asc('POST', '/v1/reviewSubmissions', { data: { type: 'reviewSubmissions', attributes: { platform: 'IOS' }, relationships: { app: one('apps', appId) } } })).data.id as string });
|
||||
if (r.created) log(tag, `draft review submission ${r.value} created`);
|
||||
return r.value;
|
||||
return { id: r.value, created: r.created };
|
||||
}
|
||||
|
||||
const TYPE: Record<string, string> = {
|
||||
|
|
@ -39,9 +47,107 @@ export async function inDraft(c: Asc, sid: string, kind: string, id: string): Pr
|
|||
* ago). true when it was added now. */
|
||||
export async function addItem(c: Asc, sid: string, kind: string, id: string, label: string, tag: string, o: { checked?: boolean } = {}): Promise<boolean> {
|
||||
if (!o.checked && await inDraft(c, sid, kind, id)) { log(tag, `${label}: already in draft ${sid}`); return false; }
|
||||
const r = await createOnce({ what: `${label} in draft ${sid}`, tag, unique: true,
|
||||
find: async () => ((await inDraft(c, sid, kind, id)) ? id : null),
|
||||
create: async () => (await c.asc('POST', '/v1/reviewSubmissionItems', { data: { type: 'reviewSubmissionItems',
|
||||
relationships: { reviewSubmission: one('reviewSubmissions', sid), [kind]: one(TYPE[kind] ?? `${kind}s`, id) } } })).data?.id ?? id });
|
||||
return r.created;
|
||||
try {
|
||||
const r = await createOnce({ what: `${label} in draft ${sid}`, tag, unique: true,
|
||||
find: async () => ((await inDraft(c, sid, kind, id)) ? id : null),
|
||||
create: async () => (await c.asc('POST', '/v1/reviewSubmissionItems', { data: { type: 'reviewSubmissionItems',
|
||||
relationships: { reviewSubmission: one('reviewSubmissions', sid), [kind]: one(TYPE[kind] ?? `${kind}s`, id) } } })).data?.id ?? id });
|
||||
return r.created;
|
||||
} catch (e) {
|
||||
const other = otherSubmissionOf(e);
|
||||
if (other === null) throw e;
|
||||
throw new OtherSubmissionError(other, kind, id, `${label} is already in another review submission, ${other} (not ${sid}): Apple keeps an item in one submission only `
|
||||
+ `(409 ITEM_PART_OF_ANOTHER_SUBMISSION). That submission is the one to use; nothing was added twice.\n${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------- open submissions, resubmit
|
||||
|
||||
/** Review submission states that hold their items: a draft, one back from App Review with issues (rejected: the
|
||||
* owner fixes and resubmits the same submission), and one waiting for or in review. */
|
||||
export const OPEN_STATES = ['READY_FOR_REVIEW', 'UNRESOLVED_ISSUES', 'WAITING_FOR_REVIEW', 'IN_REVIEW'] as const;
|
||||
|
||||
export interface SubItem { id: string; kind: string; versionId: string; state: string }
|
||||
export interface OpenSubmission { id: string; state: string; submittedDate?: string; items: SubItem[] }
|
||||
|
||||
/** The items of a review submission: kind (appStoreVersion, inAppPurchaseVersion, ...), the version id, the item state. */
|
||||
export async function readItems(c: Asc, sid: string): Promise<SubItem[]> {
|
||||
const items = await c.all(`/v1/reviewSubmissions/${sid}/items?include=appStoreVersion,inAppPurchaseVersion,subscriptionVersion,subscriptionGroupVersion&limit=200`);
|
||||
return items.map((it: J) => {
|
||||
const [kind, rel] = Object.entries(it.relationships ?? {}).find(([k, r]) => k !== 'reviewSubmission' && (r as J)?.data?.id) ?? ['?', null];
|
||||
return { id: it.id, kind, versionId: (rel as J)?.data?.id ?? '?', state: it.attributes?.state ?? '?' };
|
||||
});
|
||||
}
|
||||
|
||||
/** Every open review submission of the app on iOS (OPEN_STATES), each with its items. `also`: a submission id a 409
|
||||
* named, read directly (a list may lag behind, or its state may be outside the filter). */
|
||||
export async function openSubmissions(c: Asc, appId: string, also?: string): Promise<OpenSubmission[]> {
|
||||
const subs = await c.all(`/v1/apps/${appId}/reviewSubmissions?filter[platform]=IOS&filter[state]=${OPEN_STATES.join(',')}&include=items&limit=50`);
|
||||
if (also && !subs.some((x: J) => x.id === also)) { const d = (await c.maybe(`/v1/reviewSubmissions/${also}`))?.data; if (d) subs.push(d); }
|
||||
const out: OpenSubmission[] = [];
|
||||
for (const x of subs) out.push({ id: x.id, state: x.attributes?.state ?? '?', submittedDate: x.attributes?.submittedDate ?? undefined, items: await readItems(c, x.id) });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The submission id named by a 409 ITEM_PART_OF_ANOTHER_SUBMISSION ("... was already added to another
|
||||
* reviewSubmission with id X"), '?' when the code is there without an id, null for any other error. Pure. */
|
||||
export function otherSubmissionOf(e: unknown): string | null {
|
||||
const s = String((e as Error)?.message ?? e);
|
||||
if (!/ITEM_PART_OF_ANOTHER_SUBMISSION/.test(s)) return null;
|
||||
return /another reviewSubmission with id ([\w-]+)/i.exec(s)?.[1] ?? '?';
|
||||
}
|
||||
|
||||
/** An item could not go into this submission because another one holds it. */
|
||||
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;
|
||||
}
|
||||
|
||||
/** Waits of the submit retry (injectable: tests replace sleep and now, nothing waits for real). */
|
||||
export const submitTiming = {
|
||||
delaysMs: [15_000, 30_000, 60_000, 120_000],
|
||||
timeoutMs: 10 * 60_000,
|
||||
heartbeatMs: 30_000,
|
||||
sleep: (ms: number): Promise<void> => (ms > 0 ? Bun.sleep(ms) : Promise.resolve()),
|
||||
now: (): number => Date.now(),
|
||||
};
|
||||
|
||||
/** 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> {
|
||||
const t = submitTiming;
|
||||
const start = t.now();
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
await c.asc('PATCH', `/v1/reviewSubmissions/${sid}`, { data: { type: 'reviewSubmissions', id: sid, attributes: { submitted: true } } });
|
||||
if (attempt > 1) log(tag, `review submission ${sid}: accepted on try ${attempt}`);
|
||||
return;
|
||||
} catch (e) {
|
||||
if (!notReadyYet(e)) throw e;
|
||||
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`
|
||||
+ `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`);
|
||||
for (let left = delay; left > 0;) {
|
||||
const step = Math.min(left, t.heartbeatMs);
|
||||
await t.sleep(step);
|
||||
left -= step;
|
||||
if (left > 0) log(tag, `review submission ${sid}: waiting, next try in ${Math.round(left / 1000)} s`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue