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:
CRTkafa 2026-10-12 10:33:48 +08:00
parent c8213be2b6
commit 0da5ee9d01
21 changed files with 726 additions and 80 deletions

View file

@ -57,8 +57,8 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'appStoreVersions_appStoreReviewDetail_getToOneRelated': 'apple/review-details validate',
'appStoreVersions_appStoreVersionLocalizations_getToManyRelated': 'apple/listing apple/previews apple/screenshots validate',
'appStoreVersions_appStoreVersionPhasedRelease_getToOneRelated': 'apple/release apple/status',
'appStoreVersions_build_getToOneRelated': 'apple/listing validate',
'appStoreVersions_build_updateToOneRelationship': 'apple/listing',
'appStoreVersions_build_getToOneRelated': 'apple/listing apple/resubmit validate',
'appStoreVersions_build_updateToOneRelationship': 'apple/listing apple/resubmit',
'appStoreVersions_createInstance': 'apple/listing',
'appStoreVersions_getInstance': 'apple/listing',
'appStoreVersions_updateInstance': 'apple/listing',
@ -70,7 +70,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'apps_appPricePoints_getToManyRelated': 'apple/appinfo',
'apps_appPriceSchedule_getToOneRelated': 'apple/appinfo validate',
'apps_appStoreVersionExperimentsV2_getToManyRelated': 'apple/ppo',
'apps_appStoreVersions_getToManyRelated': 'apple/asc apple/iap-submit apple/listing apple/release apple/review-details apple/status lib/store-apps validate',
'apps_appStoreVersions_getToManyRelated': 'apple/asc apple/iap-submit apple/listing apple/release apple/resubmit apple/review-details apple/status lib/store-apps validate',
'apps_betaAppLocalizations_getToManyRelated': 'apple/testflight',
'apps_betaAppReviewDetail_getToOneRelated': 'apple/testflight',
'apps_betaFeedbackCrashSubmissions_getToManyRelated': 'health',
@ -83,6 +83,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'apps_perfPowerMetrics_getToManyRelated': 'health',
'apps_promotedPurchases_getToManyRelated': 'apple/promoted',
'apps_promotedPurchases_replaceToManyRelationship': 'lib/offers',
'apps_reviewSubmissions_getToManyRelated': 'apple/submission',
'apps_subscriptionGroups_getToManyRelated': 'apple/iap-submit apple/promoted apple/subscriptions apple/win-back catalog prices validate',
'apps_updateInstance': 'apple/appinfo',
'betaAppLocalizations_createInstance': 'lib/testflight',
@ -112,7 +113,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'builds_betaAppReviewSubmission_getToOneRelated': 'apple/testflight',
'builds_betaBuildLocalizations_getToManyRelated': 'apple/testflight',
'builds_diagnosticSignatures_getToManyRelated': 'health',
'builds_getCollection': 'apple/build apple/encryption apple/listing apple/next-build apple/status apple/testflight apple/upload health lib/store-apps triage',
'builds_getCollection': 'apple/build apple/encryption apple/listing apple/next-build apple/resubmit apple/status apple/testflight apple/upload health lib/store-apps triage',
'builds_updateInstance': 'apple/encryption lib/build-ops',
'bundleIdCapabilities_createInstance': 'lib/signing',
'bundleIds_createInstance': 'apple/bundle',
@ -162,8 +163,8 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'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',
'reviewSubmissions_items_getToManyRelated': 'apple/iap-submit apple/submission',
'reviewSubmissions_updateInstance': 'apple/cpp apple/events apple/iap-submit apple/status lib/review-submit',
'reviewSubmissions_items_getToManyRelated': 'apple/submission',
'reviewSubmissions_updateInstance': 'apple/cpp apple/events apple/status apple/submission lib/review-submit',
'salesReports_getCollection': 'lib/reports',
'subscriptionAppStoreReviewScreenshots_createInstance': 'apple/subscriptions',
'subscriptionAppStoreReviewScreenshots_deleteInstance': 'apple/subscriptions',

View file

@ -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
View 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 });
}

View file

@ -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})` : ''}`);
}
}

View file

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

View file

@ -60,7 +60,8 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
'apple iap': [() => import('./apple/iap'), 'in-app purchases, localizations, price schedules, availability'],
'apple subscriptions': [() => import('./apple/subscriptions'), 'subscription groups, subscriptions, localizations, prices (equalized + overrides + --csv), intro offers, availability, review screenshot (asks first)'],
'apple iap-review': [() => import('./apple/iap-review'), 'IAP review screenshots + product states'],
'apple iap-submit': [() => import('./apple/iap-submit'), 'IAP + subscription versions into a review submission (first of a type: --with-version), then submit (asks first; --no-submit keeps a draft)'],
'apple iap-submit': [() => import('./apple/iap-submit'), 'IAP + subscription versions into a review submission (first of a type: --with-version), then submit (asks first; --no-submit keeps a draft); reuses the open submission, never a second one'],
'apple resubmit': [() => import('./apple/resubmit'), 'after a rejection: --build <n> attached to the rejected version, the UNRESOLVED_ISSUES submission reused (missing items added), resubmitted with retry (dry run unless --yes)'],
'apple offer-codes': [() => import('./apple/offer-codes'), 'offer codes for one-time purchases (campaigns; custom and one-time-use codes with --codes, asks first)'],
'apple win-back': [() => import('./apple/win-back'), 'win-back offers for lapsed subscribers (asks first: they go live on their start date)'],
'apple promoted': [() => import('./apple/promoted'), 'promoted in-app purchases on the product page: create, order, --enable (asks first)'],

View file

@ -151,6 +151,13 @@
"phased",
"version"
],
"apple resubmit": [
"build",
"new-version",
"no-submit",
"version",
"with-version"
],
"apple review-details": [
"contact-from"
],
@ -971,6 +978,9 @@
"storebound apple release --phased on|pause|resume|complete [--version 1.2.0] [--dry] [--yes]",
"storebound apple release --manual-release [--version 1.2.0] [--yes]"
],
"apple resubmit": [
"storebound apple resubmit --build <n> [--version 1.0.0] [--dry] [--yes] [--json]"
],
"apple review-details": [
"storebound apple review-details [--contact-from <Apple ID of another app>] [--dry]"
],

View file

@ -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'],

View file

@ -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');
}

View file

@ -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/, ''));
}