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
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -2,6 +2,19 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- Fix (`apple iap-submit`): resubmitting after a rejection created a second, empty draft next to the rejected
|
||||
submission and stopped with a wrong "list lag" message (pitfall 30). The open review submissions are read first
|
||||
(`GET /v1/apps/{id}/reviewSubmissions`, READY_FOR_REVIEW, UNRESOLVED_ISSUES, WAITING_FOR_REVIEW, IN_REVIEW, with
|
||||
their items): one with UNRESOLVED_ISSUES is reused (only missing items added, then resubmitted), a draft is reused,
|
||||
one waiting for or in review means nothing is sent; a new draft only when none is open. 409
|
||||
`ITEM_PART_OF_ANOTHER_SUBMISSION` is reported with the other submission's id and that one is used (a draft the run
|
||||
created is canceled again); it is never read as a duplicate. The submit is retried with backoff and heartbeat lines
|
||||
while Apple answers "Version is not ready to be submitted yet" (up to 10 minutes).
|
||||
- New `storebound apple resubmit --build <n> [--version x.y.z]`: attaches build n (by number, VALID) to the rejected
|
||||
version, reuses the UNRESOLVED_ISSUES submission, adds missing IAP / subscription items and resubmits with the
|
||||
retry. A plan table without `--yes` (the owner's step); never in MCP or flows. `apple status` lists an
|
||||
UNRESOLVED_ISSUES submission with its items and their states and prints the resubmit command.
|
||||
|
||||
- ITMS-91064 (pitfall 29): a real build was refused as Invalid Binary a minute after the upload because the app's
|
||||
PrivacyInfo.xcprivacy had NSPrivacyTracking true with an empty NSPrivacyTrackingDomains (tracking came from an ad
|
||||
SDK whose own manifest declared it). Rule in every manifest: true needs a non-empty list, a non-empty list needs
|
||||
|
|
|
|||
|
|
@ -383,7 +383,8 @@ run in the background with a log (`storebound shots render > .storebound/render.
|
|||
| `apple screenshots [--variant b]` | API | 6.9" + 6.5" (+ 13" iPad with `apple.ipad`, + 6.3" with `apple.iphoneMedium`) per locale, MD5-idempotent |
|
||||
| `apple previews [--poster 5]` | API | app preview videos per locale, checked first, MD5-idempotent, poster frame |
|
||||
| `apple iap` / `apple iap-review` | API | IAPs (consumable, non-consumable, non-renewing), localizations, price schedules, availability / review screenshots |
|
||||
| `apple iap-submit [--with-version] [--no-submit]` | API | IAP / subscription / group versions into a review submission (the first of a type together with the app version); asks before adding and before submitting |
|
||||
| `apple iap-submit [--with-version] [--no-submit]` | API | IAP / subscription / group versions into a review submission (the first of a type together with the app version); reuses the open submission (a draft, or one back from review with UNRESOLVED_ISSUES), never a second one; asks before adding and before submitting |
|
||||
| `apple resubmit --build <n>` | API | after a rejection: attaches build n to the rejected version, reuses the UNRESOLVED_ISSUES submission, adds missing items and resubmits (retried while Apple says "not ready yet"); plan table without `--yes` |
|
||||
| `apple offer-codes [--codes]` | API | offer code campaigns for one-time purchases; custom and one-time-use codes only with `--codes` (asks; codes saved to `.storebound/offer-codes/`) |
|
||||
| `apple win-back` | API | win-back offers for lapsed subscribers (asks: they go live on their start date) |
|
||||
| `apple promoted [--enable]` | API | promoted in-app purchases: create (disabled), order, switch on (asks) |
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)'],
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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/, ''));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
|
|||
|
||||
| API | operations | GET | other | wrapped by a command | owner only |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| App Store Connect API 4.5.1 | 1303 | 826 | 477 | 197 | 166 |
|
||||
| App Store Connect API 4.5.1 | 1303 | 826 | 477 | 198 | 166 |
|
||||
| Google Play Android Developer API v3 (revision 20261008) | 145 | 45 | 100 | 33 | 49 |
|
||||
| Google Play Developer Reporting API v1beta1 (revision 20261008) | 25 | 15 | 10 | 6 | 0 |
|
||||
|
||||
|
|
@ -377,9 +377,9 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
|
|||
| `appStoreVersions_appStoreVersionPhasedRelease_getToOneRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionPhasedRelease` | read | |
|
||||
| `appStoreVersions_appStoreVersionSubmission_getToOneRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionSubmission` | read | |
|
||||
| `appStoreVersions_appStoreVersionSubmission_getToOneRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionSubmission` | read | |
|
||||
| `appStoreVersions_build_getToOneRelated` | GET | `/v1/appStoreVersions/{id}/build` | read | apple/listing, validate |
|
||||
| `appStoreVersions_build_getToOneRelated` | GET | `/v1/appStoreVersions/{id}/build` | read | apple/listing, apple/resubmit, validate |
|
||||
| `appStoreVersions_build_getToOneRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/build` | read | |
|
||||
| `appStoreVersions_build_updateToOneRelationship` | PATCH | `/v1/appStoreVersions/{id}/relationships/build` | write (dry by default) | apple/listing |
|
||||
| `appStoreVersions_build_updateToOneRelationship` | PATCH | `/v1/appStoreVersions/{id}/relationships/build` | write (dry by default) | apple/listing, apple/resubmit |
|
||||
| `appStoreVersions_createInstance` | POST | `/v1/appStoreVersions` | write (dry by default) | apple/listing |
|
||||
| `appStoreVersions_customerReviews_getToManyRelated` | GET | `/v1/appStoreVersions/{id}/customerReviews` | read | |
|
||||
| `appStoreVersions_customerReviews_getToManyRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/customerReviews` | read | |
|
||||
|
|
@ -421,7 +421,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
|
|||
| `apps_appPriceSchedule_getToOneRelationship` | GET | `/v1/apps/{id}/relationships/appPriceSchedule` | read | |
|
||||
| `apps_appStoreVersionExperimentsV2_getToManyRelated` | GET | `/v1/apps/{id}/appStoreVersionExperimentsV2` | read | apple/ppo |
|
||||
| `apps_appStoreVersionExperimentsV2_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appStoreVersionExperimentsV2` | read | |
|
||||
| `apps_appStoreVersions_getToManyRelated` | GET | `/v1/apps/{id}/appStoreVersions` | read | apple/asc, apple/iap-submit, apple/listing, apple/release, apple/review-details, apple/status, lib/store-apps, validate |
|
||||
| `apps_appStoreVersions_getToManyRelated` | GET | `/v1/apps/{id}/appStoreVersions` | read | apple/asc, apple/iap-submit, apple/listing, apple/release, apple/resubmit, apple/review-details, apple/status, lib/store-apps, validate |
|
||||
| `apps_appStoreVersions_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appStoreVersions` | read | |
|
||||
| `apps_appTags_getToManyRelated` | GET | `/v1/apps/{id}/appTags` | read | |
|
||||
| `apps_appTags_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appTags` | read | |
|
||||
|
|
@ -473,7 +473,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
|
|||
| `apps_promotedPurchases_getToManyRelated` | GET | `/v1/apps/{id}/promotedPurchases` | read | apple/promoted |
|
||||
| `apps_promotedPurchases_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/promotedPurchases` | read | |
|
||||
| `apps_promotedPurchases_replaceToManyRelationship` | PATCH | `/v1/apps/{id}/relationships/promotedPurchases` | write (dry by default) | lib/offers |
|
||||
| `apps_reviewSubmissions_getToManyRelated` | GET | `/v1/apps/{id}/reviewSubmissions` | read | |
|
||||
| `apps_reviewSubmissions_getToManyRelated` | GET | `/v1/apps/{id}/reviewSubmissions` | read | apple/submission |
|
||||
| `apps_reviewSubmissions_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/reviewSubmissions` | read | |
|
||||
| `apps_searchKeywords_getToManyRelated` | GET | `/v1/apps/{id}/searchKeywords` | read | |
|
||||
| `apps_searchKeywords_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/searchKeywords` | read | |
|
||||
|
|
@ -651,7 +651,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
|
|||
| `builds_buildBetaDetail_getToOneRelationship` | GET | `/v1/builds/{id}/relationships/buildBetaDetail` | read | |
|
||||
| `builds_diagnosticSignatures_getToManyRelated` | GET | `/v1/builds/{id}/diagnosticSignatures` | read | health |
|
||||
| `builds_diagnosticSignatures_getToManyRelationship` | GET | `/v1/builds/{id}/relationships/diagnosticSignatures` | read | |
|
||||
| `builds_getCollection` | GET | `/v1/builds` | read | apple/build, apple/encryption, apple/listing, apple/next-build, apple/status, apple/testflight, apple/upload, health, lib/store-apps, triage |
|
||||
| `builds_getCollection` | GET | `/v1/builds` | read | apple/build, apple/encryption, apple/listing, apple/next-build, apple/resubmit, apple/status, apple/testflight, apple/upload, health, lib/store-apps, triage |
|
||||
| `builds_getInstance` | GET | `/v1/builds/{id}` | read | |
|
||||
| `builds_icons_getToManyRelated` | GET | `/v1/builds/{id}/icons` | read | |
|
||||
| `builds_icons_getToManyRelationship` | GET | `/v1/builds/{id}/relationships/icons` | read | |
|
||||
|
|
@ -1311,9 +1311,9 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
|
|||
| `reviewSubmissions_createInstance` | POST | `/v1/reviewSubmissions` | owner only (typed confirm) | apple/cpp, apple/events, apple/submission, lib/review-submit |
|
||||
| `reviewSubmissions_getCollection` | GET | `/v1/reviewSubmissions` | read | apple/events, apple/iap-submit, apple/status, apple/submission |
|
||||
| `reviewSubmissions_getInstance` | GET | `/v1/reviewSubmissions/{id}` | read | apple/submission |
|
||||
| `reviewSubmissions_items_getToManyRelated` | GET | `/v1/reviewSubmissions/{id}/items` | read | apple/iap-submit, apple/submission |
|
||||
| `reviewSubmissions_items_getToManyRelated` | GET | `/v1/reviewSubmissions/{id}/items` | read | apple/submission |
|
||||
| `reviewSubmissions_items_getToManyRelationship` | GET | `/v1/reviewSubmissions/{id}/relationships/items` | read | |
|
||||
| `reviewSubmissions_updateInstance` | PATCH | `/v1/reviewSubmissions/{id}` | owner only (typed confirm) | apple/cpp, apple/events, apple/iap-submit, apple/status, lib/review-submit |
|
||||
| `reviewSubmissions_updateInstance` | PATCH | `/v1/reviewSubmissions/{id}` | owner only (typed confirm) | apple/cpp, apple/events, apple/status, apple/submission, lib/review-submit |
|
||||
| **routingAppCoverages** (4) | | | | |
|
||||
| `routingAppCoverages_createInstance` | POST | `/v1/routingAppCoverages` | write (dry by default) | |
|
||||
| `routingAppCoverages_deleteInstance` | DELETE | `/v1/routingAppCoverages/{id}` | owner only (typed confirm) | |
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ first. No write path sends the same values again on a second run.
|
|||
| `apple cpp create` | page name; locale | page: findOrCreate + journal; locales from a fresh read, 409 = exists | ok T |
|
||||
| `apple cpp upload` / `apple ppo upload` / `apple screenshots` | display type; position + MD5 | set: findOrCreate (unique, 409 = found); screenshots: matching prefix kept, rest replaced in order | ok T (twice, crash on the 3rd file) |
|
||||
| `apple cpp submit` / `apple events submit` / `apple iap-submit` | the app's draft submission; version id per item | draft reused or created once (journal); an item in the draft is not added again | ok T (iap-submit) (owner: submit asks) |
|
||||
| `apple iap-submit` / `apple resubmit` | the app's open review submission (draft or UNRESOLVED_ISSUES); version id per item | the open submission reused, never a second one; only missing items added; a draft the run created and could not use (409 ITEM_PART_OF_ANOTHER_SUBMISSION) canceled; a re-run after the submit sends nothing | ok T (resubmit) (owner: --yes) |
|
||||
| `apple events create` | reference name; locale | event: findOrCreate + journal; localizations planned from a fresh read | ok T |
|
||||
| `apple events upload` | asset type per locale (size + delivered) | kept when the same size is delivered, else replaced | ok |
|
||||
| `apple ppo create` | test name (open tests only); treatment name; locale | findOrCreate + journal for test and treatments | ok T |
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ fails without the fix.
|
|||
|
||||
| # | date | lesson | guard |
|
||||
|---|---|---|---|
|
||||
| 30 | 2026-10 | Resubmitting after an Invalid Binary rejection: `apple iap-submit --with-version --yes` created a second, empty draft next to the rejected submission (UNRESOLVED_ISSUES, holding the REJECTED version and 7 IAP versions) and stopped with "the store says it already exists but does not list it yet (lists lag)". The answer was really 409 `ITEM_PART_OF_ANOTHER_SUBMISSION` naming the rejected submission; and right after a new build is attached Apple answers the submit with 409 "Version is not ready to be submitted yet". | `chooseSubmission` lists the open submissions (READY_FOR_REVIEW, UNRESOLVED_ISSUES, WAITING_FOR_REVIEW, IN_REVIEW) and reuses one, never creating a second; `isConflict` excludes the code and `addItem` names the other submission, a draft the run created is canceled and the named one used; `submitWithRetry` (15 s, 30 s, 60 s, 2 min, heartbeat, 10 min limit); new `apple resubmit --build <n>` (build by number, plan table, `--yes`); `apple status` shows the UNRESOLVED_ISSUES items and the command. `tests/resubmit.test.ts`. |
|
||||
| 29 | 2026-10 | Apple refused build 1 as Invalid Binary one minute after submission: "ITMS-91064: Invalid tracking information - A PrivacyInfo.xcprivacy file contains invalid tracking information at the following path: "PrivacyInfo.xcprivacy". NSPrivacyTracking must be true if NSPrivacyTrackingDomains isn't empty. Keys and values in your app's privacy manifests must be valid." The app's own manifest had NSPrivacyTracking true with an EMPTY NSPrivacyTrackingDomains, as storebound recommended from `appPrivacy.tracking: true` although the tracking came from an ad SDK (LevelPlay) whose own manifest declared it. Apple's wording names the other direction (domains listed, tracking false); both are invalid. After the fix (app manifest false, App Privacy still "used for tracking") `check` wrongly demanded the app manifest say true. | Rule (`trackingProblem` / `trackingIssue` / `itms91064` in `cli/lib/privacy-manifest.ts`): a manifest's NSPrivacyTracking and NSPrivacyTrackingDomains agree, in the app's and every SDK's; `check`, `scan` (blocker) and `privacy-manifest` (blocker) fail on either mismatch with the exact ITMS text and never suggest true without domains. App Privacy tracking no longer needs the app manifest's true: `check` looks for SDK manifests (Pods, frameworks, SwiftPM checkouts, Carthage, Capacitor plugins, the last built IPA; `cli/lib/privacy-files.ts`) that declare tracking (info), known tracking SDKs in the dependencies (info), else warns. `privacy-manifest --propose` and the scan proposal write true only with `appPrivacy.trackingDomains` (the app's own domains), else false. `apple upload` reads every PrivacyInfo.xcprivacy in the IPA (app root, Frameworks, bundles; XML or binary) and stops before any network call; `apple build` checks the exported IPA. `tests/privacy-tracking.test.ts`. |
|
||||
| 28 | 2026-10 | First App Store release with IAPs: the console route failed an agent (the IAP page's "Add for Review" menu ignored scripted clicks; the version page no longer has an "In-App Purchases and Subscriptions" section), while `apple iap-submit --with-version --yes` added the IAPs to the draft submission that held 1.0.0 and submitted them first try. App Privacy's Publish opens a "Publish Your App Privacy Responses?" dialog and leaves a "Published … ago by …" line. | appstore-first-release and store-iap-setup point to `apple iap-submit --with-version` (the owner's yes: it submits), never the console. `console verify apple-app-privacy` reads the "Published …" line as the published signal; the plan names the dialog. `tests/console-play-plans.test.ts`. |
|
||||
| 27 | 2026-10 | A real Play first release (an agent with a browser tool) found the steps no API does: the App category stayed "Not selected" (`play listing` cannot set it: edits.details has no category field); production had 0 countries (edits.countryAvailability is read-only); App content had ten cards with their own pages; the IARC questionnaire is one long page whose Yes answers reveal follow-ups, with an IARC Terms of Use agreement on page 1 and a final "Save" (not "Submit") on the Summary; Play's material checkboxes, like its radios, ignore native input; long evaluate calls timed out at 45 s; "Send changes for review" ignored a JS click; managed publishing is off by default, so approved means live. | Plans `play-store-settings`, `play-app-content` (+ one per card), `play-production-countries`, `play-production-release`; config `play.category` (Play's lists), `play.countries`, `play.managedPublishing`, `console.contentRating.details`, `console.appContent`, `social`, `products[].randomRewards`; every ask-owner answer in one batch; `check` warns without a category and counts the open App content answers; `validate` reports the category reminder and "production has 0 countries"; `play status` and `play release` say what is console-only; the IARC Terms box is never-press; material checkboxes get the inner [role=checkbox] click (plan and `console fill`); every plan ships `inPageLoop`. `tests/console-play-plans.test.ts`. |
|
||||
|
|
|
|||
|
|
@ -90,6 +90,11 @@ answers the compliance facts no file proves, all in one batch at the start.
|
|||
and the version page has no "In-App Purchases and Subscriptions" section any more. If the version was already
|
||||
submitted without them: cancel the review submission (`storebound apple status --cancel-submission <id>`, the
|
||||
owner's yes), wait a minute, then `apple iap-submit --with-version` (pitfall 28).
|
||||
- Rejected (Invalid Binary, a crash, ...): the review submission stays open with UNRESOLVED_ISSUES and keeps the
|
||||
version and its IAPs. Upload a fixed build, then `storebound apple resubmit --build <n>` (dry run with the plan
|
||||
table), and after the owner's yes `--build <n> --yes`: it attaches build n, reuses that submission (no second
|
||||
draft), adds only missing items and resubmits, retrying while Apple says "not ready to be submitted yet"
|
||||
(pitfall 30, `review-rejections`). `apple iap-submit` reuses it the same way.
|
||||
- App Privacy (data types, purposes, linked, tracking) is UI-only and must be **published**, not just saved.
|
||||
- Export compliance unanswered = "Missing Compliance": `storebound apple encryption` (`export-compliance`).
|
||||
- ITMS-91053 / 91061 privacy manifest emails after upload: `privacy-manifest` skill.
|
||||
|
|
|
|||
|
|
@ -46,6 +46,19 @@ Flutter / React Native code, the Health apps declaration, alignment inside prebu
|
|||
|
||||
Start with Fix or Clarify; Push back only with evidence; Appeal last. Always ask the owner before sending anything.
|
||||
|
||||
### Apple: resubmit a fixed build (Invalid Binary, crash, ...)
|
||||
The rejected submission stays open with **UNRESOLVED_ISSUES** and keeps its items (the version, REJECTED, and its
|
||||
in-app purchases). Resubmit that same submission; never create a second one next to it (Apple then answers 409
|
||||
`ITEM_PART_OF_ANOTHER_SUBMISSION`).
|
||||
1. Upload the fixed build and note its number (`storebound apple upload`, `apple encryption` if it asks).
|
||||
2. `storebound apple status`: the submission with UNRESOLVED_ISSUES, its items and their states.
|
||||
3. `storebound apple resubmit --build <n>` (dry run without `--yes`): the plan table shows the build to attach, the
|
||||
submission reused, every item in it already or added, and the submit.
|
||||
4. The owner's yes, then `storebound apple resubmit --build <n> --yes`: build n is attached by its number, missing
|
||||
items go in, and the submit is retried with backoff while Apple says "Version is not ready to be submitted yet"
|
||||
(normal for a few minutes after a build is attached; it gives up after 10 minutes and a re-run continues).
|
||||
5. Reply in the Resolution Center what changed (template below), then `storebound apple status --wait-review`.
|
||||
|
||||
## 2. Reply template (Resolution Center / appeal, under 200 words)
|
||||
```
|
||||
Hello, thank you for the review.
|
||||
|
|
|
|||
|
|
@ -392,3 +392,18 @@ that fails without the fix (see CONTRIBUTING.md).
|
|||
--propose` writes true only with `appPrivacy.trackingDomains`; `apple upload` checks every manifest in the IPA
|
||||
(app root, Frameworks, bundles) before any network call, `apple build` after the export;
|
||||
`tests/privacy-tracking.test.ts`.
|
||||
|
||||
## 30. Resubmit after a rejection: reuse the UNRESOLVED_ISSUES submission
|
||||
- **Symptom:** after an Invalid Binary rejection, `apple iap-submit --with-version --yes` created a new, empty draft
|
||||
and stopped with "the store says it already exists but does not list it yet (its lists lag ...)". The real answer
|
||||
was 409 `ITEM_PART_OF_ANOTHER_SUBMISSION`: the version was still in the rejected submission.
|
||||
- **Cause:** a rejected submission stays open with UNRESOLVED_ISSUES and keeps its items (the REJECTED version, its
|
||||
IAPs); storebound only looked for READY_FOR_REVIEW drafts, and read the 409's "already added" as a duplicate.
|
||||
- **Do:** upload the fixed build, `storebound apple status` (shows the submission, its items and the command), then
|
||||
`storebound apple resubmit --build <n>` (dry run, plan table) and after the owner's yes `--yes`. Expect "Version is
|
||||
not ready to be submitted yet" for a few minutes after the build is attached: the command retries with backoff
|
||||
(up to 10 min) and a re-run continues where it stopped.
|
||||
- **Guard:** `chooseSubmission` (cli/lib/review-submit.ts) reuses an open submission and never creates a second one;
|
||||
`addItem` turns the 409 into a message naming the other submission (never the lag message: `isConflict` excludes
|
||||
it), a draft created by the same run is canceled and the named one used; `submitWithRetry` (cli/apple/submission.ts);
|
||||
`tests/resubmit.test.ts`.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
import { json, type Call } from './store-mock';
|
||||
|
||||
export type R = { type: string; id: string; attributes: Record<string, unknown>; rels: Record<string, string | string[]>; order: number; hiddenReads?: number };
|
||||
export interface Fault { method: string; match: RegExp; when: 'before' | 'after'; status?: number; times?: number }
|
||||
export interface Fault { method: string; match: RegExp; when: 'before' | 'after'; status?: number; times?: number; code?: string; detail?: string }
|
||||
|
||||
const ASC = 'https://api.appstoreconnect.apple.com';
|
||||
const UPLOAD_HOST = 'https://upload.example.com/';
|
||||
|
|
@ -53,6 +53,7 @@ const LISTS: Record<string, { type: string; one?: boolean }> = {
|
|||
'appStoreVersionExperimentTreatments/appStoreVersionExperimentTreatmentLocalizations': { type: 'appStoreVersionExperimentTreatmentLocalizations' },
|
||||
'appStoreVersionExperimentTreatmentLocalizations/appScreenshotSets': { type: 'appScreenshotSets' },
|
||||
'reviewSubmissions/items': { type: 'reviewSubmissionItems' },
|
||||
'apps/reviewSubmissions': { type: 'reviewSubmissions' },
|
||||
'apps/buildUploads': { type: 'buildUploads' },
|
||||
'inAppPurchases/versions': { type: 'inAppPurchaseVersions' },
|
||||
'subscriptions/versions': { type: 'subscriptionVersions' },
|
||||
|
|
@ -155,6 +156,14 @@ export class FakeAsc {
|
|||
Array.isArray(v.data) ? (v.data as { id: string }[]).map(d => temp.get(d.id) ?? d.id) : temp.get((v.data as { id: string }).id) ?? (v.data as { id: string }).id]));
|
||||
const d = body.data;
|
||||
const main: Omit<R, 'order'> = { type: d.type, id: idFor(d.type), attributes: { ...DEFAULTS[d.type], ...(d.attributes ?? {}) }, rels: relsOf(d.relationships) };
|
||||
// App Store Connect keeps an item in one open review submission only
|
||||
if (d.type === 'reviewSubmissionItems') {
|
||||
const [kind, vid] = Object.entries(main.rels).find(([k]) => k !== 'reviewSubmission') ?? [];
|
||||
const holder = this.all('reviewSubmissionItems').find(r => r.rels[kind ?? ''] === vid && r.rels.reviewSubmission !== main.rels.reviewSubmission
|
||||
&& !['COMPLETE', 'CANCELING'].includes(String(this.res.get(String(r.rels.reviewSubmission))?.attributes.state)));
|
||||
if (holder) return json({ errors: [{ status: '409', code: 'STATE_ERROR.ITEM_PART_OF_ANOTHER_SUBMISSION', title: 'The item is already part of another submission',
|
||||
detail: `${this.res.get(String(vid))?.type ?? kind} with id ${vid} was already added to another reviewSubmission with id ${holder.rels.reviewSubmission}` }] }, 409);
|
||||
}
|
||||
const u = UNIQUE[d.type];
|
||||
if (u && this.all(d.type).some(r => u(r) === u(main as R)))
|
||||
return json({ errors: [{ status: '409', code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE', title: 'The provided entity includes an attribute with a value that has already been used', detail: 'already exists' }] }, 409);
|
||||
|
|
@ -252,6 +261,10 @@ export class FakeAsc {
|
|||
const { uploaded: _u, ...rest } = attrs;
|
||||
Object.assign(self.attributes, rest, { assetDeliveryState: { state: this.parts.includes(self.id) ? 'COMPLETE' : 'FAILED' } });
|
||||
delete self.attributes.uploadOperations;
|
||||
} else if (self.type === 'reviewSubmissions' && (attrs.submitted === true || attrs.canceled === true)) {
|
||||
// submitting moves the submission (and its items) on; canceling ends it
|
||||
self.attributes.state = attrs.submitted ? 'WAITING_FOR_REVIEW' : 'CANCELING';
|
||||
if (attrs.submitted) for (const it of this.children(self.id, 'reviewSubmissionItems')) it.attributes.state = 'READY_FOR_REVIEW';
|
||||
} else Object.assign(self.attributes, attrs);
|
||||
return json({ data: this.view(self) });
|
||||
}
|
||||
|
|
@ -413,7 +426,7 @@ export function installStores(): Stores {
|
|||
s.calls.push({ method, url, path, body });
|
||||
const f = s.faults.find(x => (x.times ?? 1) > 0 && x.method === method && x.match.test(decodeURIComponent(path)));
|
||||
if (f) f.times = (f.times ?? 1) - 1;
|
||||
if (f?.when === 'before') return json({ errors: [{ status: String(f.status ?? 503), detail: 'injected' }] }, f.status ?? 503);
|
||||
if (f?.when === 'before') return json({ errors: [{ status: String(f.status ?? 503), ...(f.code ? { code: f.code } : {}), detail: f.detail ?? 'injected' }] }, f.status ?? 503);
|
||||
let res: Response;
|
||||
if (url.startsWith(UPLOAD_HOST)) {
|
||||
partNo++;
|
||||
|
|
|
|||
222
tests/resubmit.test.ts
Normal file
222
tests/resubmit.test.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
// Resubmitting after a rejection (seen live, 2026-10-12: an Invalid Binary rejection, then `apple iap-submit
|
||||
// --with-version --yes` created a second, empty draft next to the rejected submission and stopped with a wrong
|
||||
// "list lag" message on 409 ITEM_PART_OF_ANOTHER_SUBMISSION). Against the stateful App Store Connect double: the
|
||||
// UNRESOLVED_ISSUES submission is reused (no new draft), only missing items go in, the submit is retried while Apple
|
||||
// says "not ready yet" (no real waits), the 409 names the other submission, and a re-run sends nothing.
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import type { ShipConfig } from '../cli/lib/config';
|
||||
import { chooseSubmission, formatRows, type OpenSub } from '../cli/lib/review-submit';
|
||||
import { isConflict, upsertTiming } from '../cli/lib/upsert';
|
||||
import { notReadyYet, otherSubmissionOf, submitTiming } from '../cli/apple/submission';
|
||||
import { run as appleIapSubmit } from '../cli/apple/iap-submit';
|
||||
import { run as appleResubmit } from '../cli/apple/resubmit';
|
||||
import { run as appleStatus } from '../cli/apple/status';
|
||||
import { ascEnv, BASE, capture, project, restore } from './helpers/store-mock';
|
||||
import { installStores, type Stores, uninstallStores, writes } from './helpers/fake-stores';
|
||||
|
||||
const APP = '6400000101';
|
||||
const NOT_READY = { method: 'PATCH', match: /^\/v1\/reviewSubmissions\/rs-old$/, when: 'before' as const, status: 409, code: 'STATE_ERROR',
|
||||
detail: 'Version is not ready to be submitted yet, please try again later' };
|
||||
|
||||
let s: Stores;
|
||||
let slept: number[];
|
||||
const timing = { ...submitTiming };
|
||||
beforeAll(ascEnv);
|
||||
afterAll(restore);
|
||||
beforeEach(() => {
|
||||
upsertTiming.waitMs = 0;
|
||||
s = installStores();
|
||||
slept = [];
|
||||
let now = 0;
|
||||
Object.assign(submitTiming, timing, { sleep: async (ms: number) => { slept.push(ms); now += ms; }, now: () => now });
|
||||
});
|
||||
afterEach(() => { uninstallStores(); Object.assign(submitTiming, timing); });
|
||||
|
||||
const CONFIG: ShipConfig = {
|
||||
...BASE,
|
||||
products: [
|
||||
{ id: 'com.example.notes.gems', type: 'consumable', usd: 0.99, listings: { en: { title: 'Gems', description: 'A few gems.' } } },
|
||||
{ id: 'com.example.notes.pro', type: 'non_consumable', usd: 2.99, listings: { en: { title: 'Pro', description: 'Every feature.' } } },
|
||||
],
|
||||
};
|
||||
|
||||
/** The live shape: version 1.0.0 REJECTED (Invalid Binary) with build 7, submission rs-old UNRESOLVED_ISSUES holding the
|
||||
* version and the gems IAP; pro was never added; build 8 uploaded and VALID. */
|
||||
function rejected(o: { pro?: boolean } = {}): void {
|
||||
s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'REJECTED', platform: 'IOS' }, rels: { app: APP } });
|
||||
s.asc.put({ type: 'builds', id: 'b-7', attributes: { version: '7', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP, appStoreVersion: 'ver-1' } });
|
||||
s.asc.put({ type: 'builds', id: 'b-8', attributes: { version: '8', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP } });
|
||||
s.asc.put({ type: 'inAppPurchases', id: 'iap-gems', attributes: { productId: 'com.example.notes.gems', inAppPurchaseType: 'CONSUMABLE', state: 'WAITING_FOR_REVIEW' }, rels: { app: APP } });
|
||||
s.asc.put({ type: 'inAppPurchaseVersions', id: 'iapv-gems', attributes: { version: 1, state: 'READY_FOR_REVIEW' }, rels: { inAppPurchase: 'iap-gems' } });
|
||||
s.asc.put({ type: 'inAppPurchases', id: 'iap-pro', attributes: { productId: 'com.example.notes.pro', inAppPurchaseType: 'NON_CONSUMABLE', state: 'READY_TO_SUBMIT' }, rels: { app: APP } });
|
||||
s.asc.put({ type: 'inAppPurchaseVersions', id: 'iapv-pro', attributes: { version: 1, state: 'PREPARE_FOR_SUBMISSION' }, rels: { inAppPurchase: 'iap-pro' } });
|
||||
s.asc.put({ type: 'reviewSubmissions', id: 'rs-old', attributes: { platform: 'IOS', state: 'UNRESOLVED_ISSUES' }, rels: { app: APP } });
|
||||
s.asc.put({ type: 'reviewSubmissionItems', id: 'it-ver', attributes: { state: 'REJECTED' }, rels: { reviewSubmission: 'rs-old', appStoreVersion: 'ver-1' } });
|
||||
s.asc.put({ type: 'reviewSubmissionItems', id: 'it-gems', attributes: { state: 'READY_FOR_REVIEW' }, rels: { reviewSubmission: 'rs-old', inAppPurchaseVersion: 'iapv-gems' } });
|
||||
if (o.pro) s.asc.put({ type: 'reviewSubmissionItems', id: 'it-pro', attributes: { state: 'READY_FOR_REVIEW' }, rels: { reviewSubmission: 'rs-old', inAppPurchaseVersion: 'iapv-pro' } });
|
||||
}
|
||||
|
||||
const shown = (from: number): string[] => writes(s.calls.slice(from)).map(c => `${c.method} ${decodeURIComponent(c.path).replace(/\?.*/, '')}`);
|
||||
|
||||
describe('choosing the review submission', () => {
|
||||
const sub = (id: string, state: string, items: string[] = []): OpenSub => ({ id, state, items: items.map(v => ({ kind: 'appStoreVersion', versionId: v, state: 'REJECTED' })) });
|
||||
test('UNRESOLVED_ISSUES holding the version beats an empty draft; in review means nothing to do; none means create', () => {
|
||||
const ch = chooseSubmission([sub('rs-new', 'READY_FOR_REVIEW'), sub('rs-x', 'UNRESOLVED_ISSUES'), sub('rs-old', 'UNRESOLVED_ISSUES', ['ver-1'])], { appVersionId: 'ver-1' });
|
||||
expect(ch.action).toBe('reuse');
|
||||
if (ch.action === 'reuse') { expect(ch.sub.id).toBe('rs-old'); expect(ch.strayDrafts.map(d => d.id)).toEqual(['rs-new']); }
|
||||
expect(chooseSubmission([sub('rs-1', 'WAITING_FOR_REVIEW'), sub('rs-2', 'READY_FOR_REVIEW')]).action).toBe('busy');
|
||||
expect(chooseSubmission([sub('rs-2', 'READY_FOR_REVIEW')])).toMatchObject({ action: 'reuse', sub: { id: 'rs-2' } });
|
||||
expect(chooseSubmission([]).action).toBe('create');
|
||||
});
|
||||
|
||||
test('a 409 ITEM_PART_OF_ANOTHER_SUBMISSION is no duplicate (never the list-lag message) and names the other submission', () => {
|
||||
const e = new Error('POST /v1/reviewSubmissionItems -> 409 {"errors":[{"status":"409","code":"STATE_ERROR.ITEM_PART_OF_ANOTHER_SUBMISSION","detail":"appStoreVersions with id 1234567890 was already added to another reviewSubmission with id rs-example-1"}]}');
|
||||
expect(isConflict(e)).toBe(false);
|
||||
expect(otherSubmissionOf(e)).toBe('rs-example-1');
|
||||
expect(otherSubmissionOf(new Error('-> 409 ENTITY_ERROR already exists'))).toBeNull();
|
||||
expect(notReadyYet(new Error('PATCH /v1/reviewSubmissions/x -> 409 {"detail":"Version is not ready to be submitted yet, please try again later"}'))).toBe(true);
|
||||
expect(notReadyYet(new Error('PATCH /v1/reviewSubmissions/x -> 409 {"detail":"something else"}'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('apple resubmit --build <n>', () => {
|
||||
test('dry run without --yes: a plan table, nothing sent', async () => {
|
||||
rejected();
|
||||
const p = project(CONFIG);
|
||||
const a = s.calls.length;
|
||||
const r = await capture(() => appleResubmit(p.ctx(['--build', '8'])));
|
||||
expect(r.exit).toBeFalsy();
|
||||
expect(shown(a)).toEqual([]);
|
||||
expect(r.out).toContain('STEP');
|
||||
expect(r.out).toMatch(/build\s+build 8\s+attached: 7\s+attach build 8 instead of 7/);
|
||||
expect(r.out).toMatch(/submission\s+rs-old\s+UNRESOLVED_ISSUES\s+reuse \(resubmit, no new draft\)/);
|
||||
expect(r.out).toMatch(/item\s+app version 1\.0\.0\s+REJECTED\s+in it already/);
|
||||
expect(r.out).toMatch(/item\s+com\.example\.notes\.gems\s+READY_FOR_REVIEW\s+in it already/);
|
||||
expect(r.out).toMatch(/item\s+com\.example\.notes\.pro \(non-consumable, version 1\)\s+-\s+add/);
|
||||
expect(r.out).toContain('owner');
|
||||
const j = await capture(() => appleResubmit(p.ctx(['--build', '8', '--json'])));
|
||||
expect(JSON.parse(j.out)).toMatchObject({ dryRun: true, build: { action: 'attach', id: 'b-8' }, submission: { action: 'reuse', sub: { id: 'rs-old' } }, blockers: [] });
|
||||
expect(shown(a)).toEqual([]);
|
||||
});
|
||||
|
||||
test('--yes: build attached, rs-old reused (no new draft), only the missing IAP added, submit retried after "not ready"', async () => {
|
||||
rejected();
|
||||
s.faults.push({ ...NOT_READY, times: 2 });
|
||||
const p = project(CONFIG);
|
||||
const a = s.calls.length;
|
||||
const r = await capture(() => appleResubmit(p.ctx(['--build', '8', '--yes'])));
|
||||
expect([r.exit ?? 0, r.err]).toEqual([0, '']);
|
||||
expect(shown(a)).toEqual(['PATCH /v1/appStoreVersions/ver-1/relationships/build', 'POST /v1/reviewSubmissionItems',
|
||||
'PATCH /v1/reviewSubmissions/rs-old', 'PATCH /v1/reviewSubmissions/rs-old', 'PATCH /v1/reviewSubmissions/rs-old']);
|
||||
expect(s.asc.all('reviewSubmissions').map(x => [x.id, x.attributes.state])).toEqual([['rs-old', 'WAITING_FOR_REVIEW']]);
|
||||
expect(s.asc.res.get('b-8')!.rels.appStoreVersion).toBe('ver-1');
|
||||
expect(slept).toEqual([15_000, 30_000]); // 15 s after the first "not ready", 30 s after the second
|
||||
expect(r.out).toContain('not ready to be submitted yet (try 1');
|
||||
expect(r.out).toContain('accepted on try 3');
|
||||
// a re-run sends nothing: the submission waits for review now
|
||||
const b = s.calls.length;
|
||||
const again = await capture(() => appleResubmit(p.ctx(['--build', '8', '--yes'])));
|
||||
expect(again.exit).toBeFalsy();
|
||||
expect(shown(b)).toEqual([]);
|
||||
expect(again.out).toContain('nothing to resubmit');
|
||||
});
|
||||
|
||||
test('"not ready" until the timeout: a clear stop that names the submission, no other write', async () => {
|
||||
rejected({ pro: true });
|
||||
s.asc.res.get('b-7')!.rels = { app: APP }; // build 8 attached already
|
||||
s.asc.res.get('b-8')!.rels = { app: APP, appStoreVersion: 'ver-1' };
|
||||
s.faults.push({ ...NOT_READY, times: 100 });
|
||||
const p = project(CONFIG);
|
||||
await expect(appleResubmit(p.ctx(['--build', '8', '--yes']))).rejects.toThrow(/rs-old: Apple still answers "not ready to be submitted yet" after \d+ s and \d+ tries[\s\S]*re-run the same command/);
|
||||
expect(s.asc.all('reviewSubmissions')).toHaveLength(1);
|
||||
expect(slept.reduce((x, y) => x + y, 0)).toBeLessThanOrEqual(submitTiming.timeoutMs);
|
||||
expect(Math.max(...slept)).toBeLessThanOrEqual(submitTiming.heartbeatMs);
|
||||
});
|
||||
|
||||
test('a build that is not VALID or not there is refused before anything is sent', async () => {
|
||||
rejected();
|
||||
s.asc.res.get('b-8')!.attributes.processingState = 'PROCESSING';
|
||||
const p = project(CONFIG);
|
||||
const a = s.calls.length;
|
||||
const r = await capture(() => appleResubmit(p.ctx(['--build', '8', '--yes'])));
|
||||
expect(r.exit).toBe(1);
|
||||
expect(r.err).toContain('build 8 is PROCESSING');
|
||||
expect(shown(a)).toEqual([]);
|
||||
await expect(appleResubmit(p.ctx(['--yes']))).rejects.toThrow(/--build <n>/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('apple iap-submit with a submission back from App Review', () => {
|
||||
test('--with-version --yes reuses UNRESOLVED_ISSUES: no new draft, the missing item only, then resubmitted; a re-run writes nothing', async () => {
|
||||
rejected();
|
||||
s.faults.push({ ...NOT_READY, times: 1 });
|
||||
const p = project(CONFIG);
|
||||
const a = s.calls.length;
|
||||
const r = await capture(() => appleIapSubmit(p.ctx(['--with-version', '--yes'])));
|
||||
expect([r.exit ?? 0, r.err]).toEqual([0, '']);
|
||||
expect(shown(a)).toEqual(['POST /v1/reviewSubmissionItems', 'PATCH /v1/reviewSubmissions/rs-old', 'PATCH /v1/reviewSubmissions/rs-old']);
|
||||
expect(r.out).toContain('UNRESOLVED_ISSUES');
|
||||
expect(r.out).toContain('resubmitted');
|
||||
expect(s.asc.all('reviewSubmissions')).toHaveLength(1);
|
||||
const b = s.calls.length;
|
||||
const again = await capture(() => appleIapSubmit(p.ctx(['--with-version', '--yes'])));
|
||||
expect(again.exit).toBeFalsy();
|
||||
expect(shown(b)).toEqual([]);
|
||||
expect(again.out).toContain('nothing to submit');
|
||||
});
|
||||
|
||||
test('every item in it already: resubmitted as it is (the submit alone)', async () => {
|
||||
rejected({ pro: true });
|
||||
const p = project(CONFIG);
|
||||
const a = s.calls.length;
|
||||
const r = await capture(() => appleIapSubmit(p.ctx(['--with-version', '--yes'])));
|
||||
expect([r.exit ?? 0, r.err]).toEqual([0, '']);
|
||||
expect(shown(a)).toEqual(['PATCH /v1/reviewSubmissions/rs-old']);
|
||||
});
|
||||
|
||||
test('a 409 ITEM_PART_OF_ANOTHER_SUBMISSION (list missed rs-old): clear message, own empty draft canceled, rs-old used', async () => {
|
||||
rejected();
|
||||
// the list read does not show rs-old (e.g. a state outside the filter): the run creates a draft, the add answers 409
|
||||
s.asc.res.get('rs-old')!.attributes.platform = 'MAC_OS';
|
||||
const p = project(CONFIG);
|
||||
const a = s.calls.length;
|
||||
const r = await capture(() => appleIapSubmit(p.ctx(['--with-version', '--yes'])));
|
||||
expect(r.exit).toBeFalsy();
|
||||
expect(r.err).toContain('app version 1.0.0 is already in another review submission, rs-old');
|
||||
expect(`${r.out}\n${r.err}`).not.toContain('lag');
|
||||
const w = shown(a);
|
||||
const created = s.asc.all('reviewSubmissions').find(x => x.id !== 'rs-old')!;
|
||||
expect(w.slice(0, 3)).toEqual(['POST /v1/reviewSubmissions', 'POST /v1/reviewSubmissionItems', `PATCH /v1/reviewSubmissions/${created.id}`]);
|
||||
expect(created.attributes.state).toBe('CANCELING');
|
||||
expect(w.slice(3)).toEqual(['POST /v1/reviewSubmissionItems', 'PATCH /v1/reviewSubmissions/rs-old']);
|
||||
expect(s.asc.res.get('rs-old')!.attributes.state).toBe('WAITING_FOR_REVIEW');
|
||||
});
|
||||
|
||||
test('a submission waiting for review: nothing sent, said so', async () => {
|
||||
rejected();
|
||||
s.asc.res.get('rs-old')!.attributes.state = 'WAITING_FOR_REVIEW';
|
||||
const p = project(CONFIG);
|
||||
const a = s.calls.length;
|
||||
const r = await capture(() => appleIapSubmit(p.ctx(['--with-version', '--yes'])));
|
||||
expect(r.exit).toBeFalsy();
|
||||
expect(shown(a)).toEqual([]);
|
||||
expect(r.out).toContain('rs-old is WAITING_FOR_REVIEW');
|
||||
});
|
||||
});
|
||||
|
||||
describe('apple status', () => {
|
||||
test('an UNRESOLVED_ISSUES submission with its items and the resubmit command', async () => {
|
||||
rejected();
|
||||
const p = project(CONFIG);
|
||||
const r = await capture(() => appleStatus(p.ctx([])));
|
||||
expect(r.out).toContain('review submission rs-old is back from App Review with UNRESOLVED_ISSUES');
|
||||
expect(r.out).toMatch(/app version 1\.0\.0\s+REJECTED/);
|
||||
expect(r.out).toMatch(/inAppPurchaseVersion iapv-gems\s+READY_FOR_REVIEW/);
|
||||
expect(r.out).toContain('resubmit with: storebound apple resubmit --build <n>');
|
||||
});
|
||||
});
|
||||
|
||||
test('formatRows aligns the columns', () => {
|
||||
expect(formatRows([{ step: 'build', what: 'build 8', now: 'attached: 7', action: 'attach' }]).split('\n')).toEqual([
|
||||
'STEP WHAT NOW ACTION', 'build build 8 attached: 7 attach']);
|
||||
});
|
||||
|
|
@ -150,6 +150,7 @@ describe('IAP review submission plan', () => {
|
|||
{ type: 'inAppPurchases', id: 'i-coins2', attributes: { productId: 'com.example.notes.coins_500', inAppPurchaseType: 'CONSUMABLE', state: 'READY_TO_SUBMIT' } }]) },
|
||||
{ match: /^\/v2\/inAppPurchases\/i-coins2\/versions/, answer: page([{ type: 'inAppPurchaseVersions', id: 'iv-2', attributes: { version: 1, state: 'PREPARE_FOR_SUBMISSION' } }]) },
|
||||
{ match: /^\/v1\/apps\/6400000101\/subscriptionGroups/, answer: page([]) },
|
||||
{ match: /^\/v1\/apps\/6400000101\/reviewSubmissions\?filter\[platform\]=IOS&filter\[state\]=READY_FOR_REVIEW,UNRESOLVED_ISSUES,WAITING_FOR_REVIEW,IN_REVIEW/, answer: page([]) },
|
||||
{ match: /^\/v1\/reviewSubmissions\?filter\[app\]=6400000101&filter\[platform\]=IOS&filter\[state\]=READY_FOR_REVIEW/, answer: page([]) },
|
||||
{ method: 'POST', match: /^\/v1\/reviewSubmissions$/, answer: { data: { type: 'reviewSubmissions', id: 'rs-new', attributes: { state: 'READY_FOR_REVIEW' } } } },
|
||||
{ method: 'POST', match: /^\/v1\/reviewSubmissionItems$/, answer: { data: { type: 'reviewSubmissionItems', id: 'it-1' } } },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue