From 1724296e5955bcbae7d53b1e7a156abb527ba9a6 Mon Sep 17 00:00:00 2001 From: CRT Date: Mon, 12 Oct 2026 10:45:05 +0800 Subject: [PATCH] apple resubmit: REJECTED review submission items are marked resolved before the submit (PATCH reviewSubmissionItems resolved: true; the version item by default, IAP / subscription items with --resolve-iaps), plan table 'mark resolved' rows; on 409 'not ready yet' the items are re-read and a REJECTED item out of scope stops the run at once naming it and how to resolve it, backoff only when none is left; an empty READY_FOR_REVIEW draft is never canceled (409 'not in cancellable state'): left as is, no cancel command, apple status labels it harmless and treats the cancel 409 as such; fake ASC blocks submit on REJECTED items and refuses to cancel empty drafts. Pitfall 30 --- CHANGELOG.md | 11 ++++ README.md | 4 +- cli/api/wrapped.gen.ts | 1 + cli/apple/iap-submit.ts | 49 ++++++++++---- cli/apple/resubmit.ts | 22 +++++-- cli/apple/status.ts | 23 +++++-- cli/apple/submission.ts | 64 +++++++++++++++---- cli/data/flags.json | 7 +- cli/lib/review-submit.ts | 37 +++++++++-- docs/api-coverage.md | 4 +- docs/idempotency.md | 2 +- docs/pitfalls.md | 2 +- skills/appstore-first-release/SKILL.md | 6 +- skills/review-rejections/SKILL.md | 12 +++- skills/store-pitfalls/SKILL.md | 26 ++++++-- tests/helpers/fake-stores.ts | 11 ++++ tests/resubmit.test.ts | 88 +++++++++++++++++++++----- 17 files changed, 291 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ebdfe1..6d54f7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +- Fix (`apple resubmit`, `apple iap-submit`): after an Invalid Binary rejection the submit kept answering 409 "Version + is not ready to be submitted yet" for 25 minutes because the app version item of the UNRESOLVED_ISSUES submission + was still REJECTED (pitfall 30). REJECTED items are now marked resolved before the submit + (`PATCH /v1/reviewSubmissionItems/{id}` `resolved: true`): the app version item in `apple resubmit` (a fixed build + goes in), in-app purchase / subscription items only with the new `--resolve-iaps`. The plan table shows "mark + resolved" rows. On "not ready" the items are read again: one in scope is resolved, one out of scope stops the run + at once with the item and how to resolve it; backoff retries only when nothing is left REJECTED. +- Fix: an empty READY_FOR_REVIEW draft cannot be canceled (409 "Resource is not in cancellable state"). A draft the + run created and could not use is left as is, stray empty drafts get no cancel command, `apple status` labels them + "empty draft (harmless; App Store Connect keeps it, not cancellable)" and `--cancel-submission` treats that 409 as + harmless. `apple status` marks REJECTED items as not resolved. - 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 diff --git a/README.md b/README.md index cf8cc79..9e23a3f 100644 --- a/README.md +++ b/README.md @@ -383,8 +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); 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 ` | 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 iap-submit [--with-version] [--resolve-iaps] [--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; `--resolve-iaps` marks its REJECTED IAP items resolved), never a second one; asks before adding and before submitting | +| `apple resubmit --build [--resolve-iaps]` | API | after a rejection: attaches build n to the rejected version, reuses the UNRESOLVED_ISSUES submission, adds missing items, marks the REJECTED version item resolved (IAP items with `--resolve-iaps`) and resubmits (a REJECTED item left stops it at once; retried while Apple says "not ready yet" only when none is left); 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) | diff --git a/cli/api/wrapped.gen.ts b/cli/api/wrapped.gen.ts index b14802a..5649899 100644 --- a/cli/api/wrapped.gen.ts +++ b/cli/api/wrapped.gen.ts @@ -160,6 +160,7 @@ export const WRAPPED: { apple: Record; play: Record, which attaches the fixed build first. import { sellsOn } from '../lib/config'; import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { done, log, printJson, warn } from '../lib/log'; import { allSubscriptions } from '../lib/subscriptions'; -import { bindIds, chooseSubmission, latestVersion, newVersionReq, type PIap, planIapSubmission, type PSub, type SubmitState, versionsOf, willSubmit } from '../lib/review-submit'; +import { bindIds, chooseSubmission, EMPTY_DRAFT, labelOf, type ResolveScope, resolveHint, toResolve, latestVersion, newVersionReq, type PIap, planIapSubmission, type PSub, type SubmitState, versionsOf, willSubmit } from '../lib/review-submit'; import { readJournal } from '../lib/upsert'; import { type Asc, ascClient, type J } from './asc'; import { describeReq, execute } from './jsonapi'; -import { addItem, cancelUnusedDraft, draftJournalKey, ensureDraft, openSubmissions, OtherSubmissionError, submitWithRetry } from './submission'; +import { addItem, draftJournalKey, ensureDraft, leaveUnusedDraft, openSubmissions, OtherSubmissionError, resolveRejected, submitWithRetry } from './submission'; const TAG = 'iap-submit'; @@ -76,7 +79,7 @@ export async function run(ctx: Ctx): Promise { if (!dry) state = await readSubmitState(c, appId, ids); } } - await submitPlanned(ctx, c, { appId, ids, state, tag: TAG, withVersion: a.has('with-version') }); + await submitPlanned(ctx, c, { appId, ids, state, tag: TAG, withVersion: a.has('with-version'), resolve: { version: false, iaps: a.has('resolve-iaps') } }); } /** Lines that say which submission is used and what else is open next to it. */ @@ -84,7 +87,7 @@ export function choiceLines(state: SubmitState): string[] { const ch = state.choice; if (!ch) return []; const out = [ch.why]; - if (ch.action === 'reuse') for (const d of ch.strayDrafts) out.push(`draft ${d.id} (${d.items.length} item(s)) is open next to it${d.items.length ? '' : ': empty, left over from an earlier run'}`); + if (ch.action === 'reuse') for (const d of ch.strayDrafts) out.push(`draft ${d.id} (${d.items.length} item(s)) is open next to it${d.items.length ? '' : `: ${EMPTY_DRAFT}`}`); return out; } @@ -94,6 +97,8 @@ export interface SubmitOpts { appVersionId?: string; /** the caller printed its own plan (resubmit) */ quiet?: boolean; + /** REJECTED items marked resolved before the submit (resubmit: the version item; --resolve-iaps: the rest) */ + resolve?: ResolveScope; /** second pass after a 409 named another submission */ retried?: boolean; } @@ -101,8 +106,9 @@ export interface SubmitOpts { /** * Plans and sends a review submission: reuses the open one (chooseSubmission), adds only the missing items, then * submits (PATCH submitted: true, retried while Apple says "not ready yet"). A draft is created only when no open - * submission exists; one this run created and could not use (409 ITEM_PART_OF_ANOTHER_SUBMISSION) is canceled again, - * and the submission the 409 named is used instead. Shared by iap-submit and resubmit. + * submission exists; one this run created and could not use (409 ITEM_PART_OF_ANOTHER_SUBMISSION) is left as is (an empty draft is not cancellable), + * and the submission the 409 named is used instead. REJECTED items in `o.resolve` are marked resolved before the + * submit. Shared by iap-submit and resubmit. */ export async function submitPlanned(ctx: Ctx, c: Asc, o: SubmitOpts): Promise { const { root } = ctx.loaded; @@ -125,6 +131,13 @@ export async function submitPlanned(ctx: Ctx, c: Asc, o: SubmitOpts): Promise i.state === 'REJECTED')) { + if (marked.has(it)) log(tag, `${labelOf(state, it.versionId, it.kind)}: REJECTED, marked resolved before the submit`); + else warn(tag, `${labelOf(state, it.versionId, it.kind)} is REJECTED in ${state.choice.sub.id}: Apple takes no submit while it is not resolved; ${resolveHint(it.kind)}`); + } + } if (busy) { done(tag, `nothing to submit: ${state.choice!.why}`); return; } if (plan.blockers.length) { process.exitCode = 1; done(tag, 'nothing sent: fix the blockers above'); return; } if (!submitting) { done(tag, 'nothing to submit'); return; } @@ -157,7 +170,7 @@ export async function submitPlanned(ctx: Ctx, c: Asc, o: SubmitOpts): Promise labelOf(state, it.versionId, it.kind); const verb = resubmit ? 'Resubmit' : 'Submit'; - if (a.has('no-submit')) { done(tag, `${resubmit ? 'review submission' : 'draft review submission'} ${sid} ready: press "${resubmit ? 'Resubmit to App Review' : 'Submit for Review'}" in App Store Connect (Apps > Distribution > App Review)`); return; } + if (a.has('no-submit')) { + if (resubmit) await resolveRejected(c, sid, o.resolve, tag, label); + done(tag, `${resubmit ? 'review submission' : 'draft review submission'} ${sid} ready: press "${resubmit ? 'Resubmit to App Review' : 'Submit for Review'}" in App Store Connect (Apps > Distribution > App Review)`); + return; + } if (!(await confirm(ctx, `${verb} review submission ${sid} (${plan.items.length ? what : 'as it is'}) to App Review now?${plan.appVersion || resubmit ? ' This submits the app version too.' : ''}`))) { done(tag, `${sid} kept; submit it in App Store Connect or re-run`); return; } - await submitWithRetry(c, sid, tag); + if (resubmit) await resolveRejected(c, sid, o.resolve, tag, label); + await submitWithRetry(c, sid, tag, { resolve: o.resolve, label }); done(tag, `review submission ${sid} ${resubmit ? 'resubmitted' : 'submitted'} (storebound apple status shows its state)`); } -/** Empty drafts next to a reused submission: the ones storebound created (journal) are canceled, others are named. */ -async function cleanStrayDrafts(c: Asc, appId: string, root: string, state: SubmitState, tag: string): Promise { +/** Drafts next to a reused submission. An empty one is left as is: App Store Connect answers 409 "Resource is not in + * cancellable state" for it (seen live) and it does no harm; one with items is named with the cancel command. */ +function cleanStrayDrafts(root: string, appId: string, state: SubmitState, tag: string): void { if (state.choice?.action !== 'reuse') return; const mine = readJournal(root)[draftJournalKey(appId)]?.id; for (const d of state.choice.strayDrafts) { - if (!d.items.length && d.id === mine) { await cancelUnusedDraft(c, d.id, tag); continue; } + if (!d.items.length) { log(tag, `draft ${d.id}${d.id === mine ? ' (created by storebound)' : ''}: ${EMPTY_DRAFT}; left as it is`); continue; } warn(tag, `draft ${d.id} (${d.items.length} item(s)) is still open next to ${state.choice.sub.id}; cancel it if it is not needed: storebound apple status --cancel-submission ${d.id}`); } } diff --git a/cli/apple/resubmit.ts b/cli/apple/resubmit.ts index b96db8d..2bd36d8 100644 --- a/cli/apple/resubmit.ts +++ b/cli/apple/resubmit.ts @@ -1,18 +1,23 @@ // Resubmit an App Store version after a rejection (Invalid Binary, Guideline 2.1, ...) with a new build. -// storebound apple resubmit --build [--version 1.0.0] [--dry] [--yes] [--json] +// storebound apple resubmit --build [--version 1.0.0] [--resolve-iaps] [--dry] [--yes] [--json] // 1. The rejected version (--version, else the one in the submission back from App Review, else the one in a // rejected state) gets build n attached, by explicit number (never "the latest"); it must be VALID. // 2. The open review submission is reused: the one with UNRESOLVED_ISSUES (it holds the version and its in-app // purchases) gets only the missing items, never a second draft next to it (seen live: a new empty draft and then // 409 ITEM_PART_OF_ANOTHER_SUBMISSION). In-app purchases and subscriptions of the config that are not in it yet go in. -// 3. PATCH submitted: true, retried with backoff while Apple says "Version is not ready to be submitted yet" (right -// after a build is attached), with heartbeat lines and a 10-minute limit. +// 3. REJECTED items are marked resolved (PATCH reviewSubmissionItems/{id} resolved: true): the app version item, since +// a fixed build goes in; in-app purchase / subscription items only with --resolve-iaps. Seen live (2026-10-12): +// while the version item stays REJECTED, every submit answers 409 "Version is not ready to be submitted yet" and +// the console's Resubmit button is disabled; resolved, the item goes to READY_FOR_REVIEW and the submit passes. +// 4. PATCH submitted: true. On "not ready yet" the items are read again: a REJECTED one left over stops the run with +// the item and how to resolve it (retrying never passes); only when none is left is the submit retried with backoff +// (right after a build is attached), with heartbeat lines and a 10-minute limit. // Submitting is the owner's step: without --yes this is a dry run with the plan table. Not offered through MCP. import { sellsOn } from '../lib/config'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; import { done, log, printJson, warn } from '../lib/log'; -import { type BuildStep, formatRows, planIapSubmission, REJECTED_APP, resubmitRows } from '../lib/review-submit'; +import { type BuildStep, formatRows, labelOf, planIapSubmission, REJECTED_APP, type ResolveScope, resubmitRows, toResolve } from '../lib/review-submit'; import { allSubscriptions } from '../lib/subscriptions'; import { ascClient, type J } from './asc'; import { readSubmitState, submitPlanned } from './iap-submit'; @@ -53,11 +58,14 @@ export async function run(ctx: Ctx): Promise { const state = await readSubmitState(c, appId, ids, { appVersionId: v.id }); const plan = planIapSubmission(state, ids, { withVersion: true, appVersionId: v.id }); - const rows = resubmitRows(state, plan, build); + // --build is a fixed build for the version: its REJECTED item is marked resolved; IAP items only on --resolve-iaps + const resolve: ResolveScope = { version: true, iaps: a.has('resolve-iaps') }; + const rows = resubmitRows(state, plan, build, resolve); + const marks = state.choice?.action === 'reuse' ? toResolve(state.choice.sub.items, resolve).map(i => ({ item: i.id ?? null, kind: i.kind, what: labelOf(state, i.versionId, i.kind) })) : []; const blockers = [...(build.action === 'refuse' ? [build.reason] : []), ...plan.blockers]; const dry = a.has('dry') || !a.has('yes'); if (a.has('json')) { - printJson({ dryRun: dry, version: { id: v.id, versionString, state: stateOf(v) }, build, submission: state.choice ?? null, rows, skipped: plan.skipped, blockers }); + printJson({ dryRun: dry, version: { id: v.id, versionString, state: stateOf(v) }, build, submission: state.choice ?? null, resolve: marks, rows, skipped: plan.skipped, blockers }); if (blockers.length) process.exitCode = 1; if (dry || blockers.length || state.choice?.action === 'busy') return; } else { @@ -76,5 +84,5 @@ export async function run(ctx: Ctx): Promise { await c.asc('PATCH', `/v1/appStoreVersions/${v.id}/relationships/build`, { data: { type: 'builds', id: build.id } }); log(TAG, `build ${number} attached to ${versionString}`); } - await submitPlanned(ctx, c, { appId, ids, state, tag: TAG, withVersion: true, appVersionId: v.id, quiet: true }); + await submitPlanned(ctx, c, { appId, ids, state, tag: TAG, withVersion: true, appVersionId: v.id, quiet: true, resolve }); } diff --git a/cli/apple/status.ts b/cli/apple/status.ts index 7a52867..ca87178 100644 --- a/cli/apple/status.ts +++ b/cli/apple/status.ts @@ -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 [--yes] +// An empty draft (READY_FOR_REVIEW, 0 items) cannot be canceled (409 "Resource is not in cancellable state"); it is +// harmless, shown as such and left as is. // A submission back from App Review (UNRESOLVED_ISSUES) is shown with its items; resubmit it with // storebound apple resubmit --build // Wait for App Review (polls with backoff: 1 min, x1.5, max 15 min): @@ -11,8 +13,9 @@ import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { log, printJson } from '../lib/log'; import { classifyReviewState, nextPollDelay, parseDuration, WAIT_EXIT } from '../lib/release'; +import { EMPTY_DRAFT } from '../lib/review-submit'; import { type Asc, ascClient, type J } from './asc'; -import { readItems, type SubItem } from './submission'; +import { notCancellable, readItems, type SubItem } from './submission'; async function waitReview(ctx: Ctx, c: Asc, appId: string): Promise { const want = ctx.args.get('version'); @@ -47,7 +50,14 @@ export async function run(ctx: Ctx): Promise { const cancel = ctx.args.get('cancel-submission'); if (cancel) { if (!(await confirm(ctx, `Cancel review submission ${cancel}? (the version goes back to "Prepare for Submission")`))) return; - await c.asc('PATCH', `/v1/reviewSubmissions/${cancel}`, { data: { type: 'reviewSubmissions', id: cancel, attributes: { canceled: true } } }); + try { + await c.asc('PATCH', `/v1/reviewSubmissions/${cancel}`, { data: { type: 'reviewSubmissions', id: cancel, attributes: { canceled: true } } }); + } catch (e) { + if (!notCancellable(e)) throw e; + log('apple', `review submission ${cancel} is not cancellable (App Store Connect answered "Resource is not in cancellable state"); left as is. ` + + `An ${EMPTY_DRAFT} does no harm: a later run reuses it`); + return; + } log('apple', `review submission ${cancel} canceled; it takes a minute to settle`); return; } @@ -60,6 +70,9 @@ export async function run(ctx: Ctx): Promise { // a submission back from App Review: its items and their states, and how to resubmit it const unresolved: { id: string; items: SubItem[] }[] = []; for (const s of subs.filter((x: J) => x.attributes?.state === 'UNRESOLVED_ISSUES')) unresolved.push({ id: s.id, items: await readItems(c, s.id) }); + // an empty draft: App Store Connect keeps it and refuses to cancel it; harmless + const empty = new Set(); + for (const s of subs.filter((x: J) => x.attributes?.state === 'READY_FOR_REVIEW')) if (!(await readItems(c, s.id)).length) empty.add(s.id); const itemLabel = (kind: string, id: string): string => { const v = versions.find((x: J) => x.id === id); return v ? `app version ${v.attributes.versionString}` : `${kind} ${id}`; @@ -83,7 +96,7 @@ export async function run(ctx: Ctx): Promise { const v = pre.get(b.relationships?.preReleaseVersion?.data?.id ?? ''); if (v) buildVersions[b.id] = v; } - printJson({ app, versions, builds: builds.data, attachedBuilds, buildVersions, iaps, subs, phased, unresolved }); + printJson({ app, versions, builds: builds.data, attachedBuilds, buildVersions, iaps, subs, phased, unresolved, emptyDrafts: [...empty] }); return; } @@ -92,10 +105,10 @@ export async function run(ctx: Ctx): Promise { const pre = new Map((builds.included ?? []).map((p: { id: string; attributes: { version: string } }) => [p.id, p.attributes.version])); for (const b of builds.data) console.log(` build ${String(pre.get(b.relationships?.preReleaseVersion?.data?.id) ?? '?')} (${b.attributes.version}) ${b.attributes.processingState} ${b.attributes.uploadedDate?.slice(0, 10) ?? ''}`); for (const p of iaps) console.log(` iap ${p.attributes.productId.padEnd(40)} ${p.attributes.inAppPurchaseType} ${p.attributes.state}`); - for (const s of subs) console.log(` review submission ${s.id} ${s.attributes.state} ${s.attributes.submittedDate?.slice(0, 10) ?? ''}`); + for (const s of subs) console.log(` review submission ${s.id} ${s.attributes.state}${empty.has(s.id) ? `: ${EMPTY_DRAFT}` : ''} ${s.attributes.submittedDate?.slice(0, 10) ?? ''}`.trimEnd()); for (const u of unresolved) { console.log(` review submission ${u.id} is back from App Review with UNRESOLVED_ISSUES; its items:`); - for (const it of u.items) console.log(` ${itemLabel(it.kind, it.versionId).padEnd(36)} ${it.state}`); + for (const it of u.items) console.log(` ${itemLabel(it.kind, it.versionId).padEnd(36)} ${it.state}${it.state === 'REJECTED' ? ' (not resolved: Apple takes no resubmit until it is; apple resubmit marks it resolved)' : ''}`); const newest = builds.data[0]?.attributes?.version; console.log(` resubmit with: storebound apple resubmit --build ${newest ? ` (newest uploaded build: ${newest})` : ''}`); } diff --git a/cli/apple/submission.ts b/cli/apple/submission.ts index 53bdf6a..96d7a5e 100644 --- a/cli/apple/submission.ts +++ b/cli/apple/submission.ts @@ -4,6 +4,7 @@ // submit. Submitting itself (PATCH submitted: true) stays the owner's confirmed step in those commands. import { log, warn } from '../lib/log'; import { createOnce, findOrCreate } from '../lib/upsert'; +import { EMPTY_DRAFT, resolveHint, type ResolveScope, toResolve } from '../lib/review-submit'; import type { Asc, J } from './asc'; import { one } from './jsonapi'; @@ -20,7 +21,7 @@ export async function draftSubmission(c: Asc, appId: string, root: string, tag: /** The journal key of the draft storebound created (cli/lib/upsert.ts). */ export const draftJournalKey = (appId: string): string => `apple:${appId}:reviewSubmission:draft`; -/** Like draftSubmission, and says whether this run created the draft (so it can be canceled again if unused). */ +/** Like draftSubmission, and says whether this run created the draft (an unused empty one is left: not cancellable). */ export async function ensureDraft(c: Asc, appId: string, root: string, tag: string): Promise<{ id: string; created: boolean }> { const r = await findOrCreate({ what: 'draft review submission', tag, unique: true, find: () => findDraft(c, appId), @@ -102,12 +103,35 @@ export class OtherSubmissionError extends Error { constructor(readonly otherId: string, readonly kind: string, readonly itemId: string, message: string) { super(message); this.name = 'OtherSubmissionError'; } } -/** Cancels a draft this run created and could not use (PATCH canceled: true), only while it is still empty. */ -export async function cancelUnusedDraft(c: Asc, sid: string, tag: string): Promise { - if ((await readItems(c, sid)).length) { warn(tag, `draft ${sid} is not empty; left as it is`); return false; } - await c.asc('PATCH', `/v1/reviewSubmissions/${sid}`, { data: { type: 'reviewSubmissions', id: sid, attributes: { canceled: true } } }); - log(tag, `empty draft ${sid} created by this run canceled (not used)`); - return true; +/** A draft this run created and could not use. Not canceled: App Store Connect answers 409 "Resource is not in + * cancellable state" for an empty READY_FOR_REVIEW draft (seen live). It is harmless and a later run reuses it. */ +export async function leaveUnusedDraft(c: Asc, sid: string, tag: string): Promise { + const n = (await readItems(c, sid)).length; + if (n) warn(tag, `draft ${sid} (${n} item(s)) is not empty; left as it is`); + else log(tag, `draft ${sid} created by this run was not used: ${EMPTY_DRAFT}; a later run reuses it`); +} + +/** Apple's 409 when an empty draft is canceled: "Resource is not in cancellable state". Pure. */ +export const notCancellable = (e: unknown): boolean => /-> 409\b/.test(String((e as Error)?.message ?? e)) && /not in cancellable state/i.test(String((e as Error)?.message ?? e)); + +/** Marks one item of a submission back from App Review resolved (seen live: the REJECTED item goes to READY_FOR_REVIEW, + * and only then does PATCH submitted: true pass). */ +export async function resolveItem(c: Asc, itemId: string, label: string, tag: string): Promise { + await c.asc('PATCH', `/v1/reviewSubmissionItems/${itemId}`, { data: { type: 'reviewSubmissionItems', id: itemId, attributes: { resolved: true } } }); + log(tag, `${label}: marked resolved (item ${itemId})`); +} + +/** Marks the REJECTED items of `sid` that `scope` covers resolved; returns the ids it marked. */ +export async function resolveRejected(c: Asc, sid: string, scope: ResolveScope | undefined, tag: string, label: (it: SubItem) => string = it => `${it.kind} ${it.versionId}`): Promise { + const out: string[] = []; + for (const it of toResolve(await readItems(c, sid), scope)) { await resolveItem(c, it.id, label(it), tag); out.push(it.id); } + return out; +} + +export interface SubmitRetryOpts { + /** REJECTED items the run may mark resolved when Apple says "not ready" */ + resolve?: ResolveScope; + label?: (it: SubItem) => string; } /** Waits of the submit retry (injectable: tests replace sleep and now, nothing waits for real). */ @@ -122,11 +146,16 @@ export const submitTiming = { /** Apple's 409 right after a new build is attached: "Version is not ready to be submitted yet, please try again later". Pure. */ export const notReadyYet = (e: unknown): boolean => /-> 409\b/.test(String((e as Error)?.message ?? e)) && /not ready to be submitted|try again later/i.test(String((e as Error)?.message ?? e)); -/** PATCH submitted: true, retried with backoff (15 s, 30 s, 60 s, then every 2 min; heartbeat lines while waiting) - * while Apple answers "not ready to be submitted yet", up to submitTiming.timeoutMs. Any other error is thrown at once. */ -export async function submitWithRetry(c: Asc, sid: string, tag: string): Promise { +/** PATCH submitted: true. While Apple answers "not ready to be submitted yet" the submission's items are read again + * first: a REJECTED item in `o.resolve` is marked resolved and the submit tried again at once; a REJECTED item out of + * scope stops the run with the item and how to resolve it (seen live: retrying never passes while one is left). Only + * when nothing is unresolved is the submit retried with backoff (15 s, 30 s, 60 s, then every 2 min; heartbeat lines), + * up to submitTiming.timeoutMs. Any other error is thrown at once. */ +export async function submitWithRetry(c: Asc, sid: string, tag: string, o: SubmitRetryOpts = {}): Promise { const t = submitTiming; const start = t.now(); + const label = o.label ?? ((it: SubItem) => `${it.kind} ${it.versionId}`); + const tried = new Set(); for (let attempt = 1; ; attempt++) { try { await c.asc('PATCH', `/v1/reviewSubmissions/${sid}`, { data: { type: 'reviewSubmissions', id: sid, attributes: { submitted: true } } }); @@ -134,14 +163,25 @@ export async function submitWithRetry(c: Asc, sid: string, tag: string): Promise return; } catch (e) { if (!notReadyYet(e)) throw e; + const rejected = (await readItems(c, sid)).filter(i => i.state === 'REJECTED'); + const mine = toResolve(rejected, o.resolve).filter(i => !tried.has(i.id)); + if (mine.length) { + for (const it of mine) { tried.add(it.id); await resolveItem(c, it.id, label(it), tag); } + continue; + } + if (rejected.length) { + throw new Error(`review submission ${sid}: Apple answers "not ready to be submitted yet" because ${rejected.length === 1 ? 'an item is' : `${rejected.length} items are`} still REJECTED ` + + `and not marked resolved; retrying would never pass, so nothing more is sent:\n${rejected.map(i => ` ${label(i)} (item ${i.id}): ${resolveHint(i.kind)}`).join('\n')}\n` + + `Last answer: ${(e as Error).message}`); + } const elapsed = t.now() - start; const delay = t.delaysMs[Math.min(attempt - 1, t.delaysMs.length - 1)]!; if (elapsed + delay > t.timeoutMs) { throw new Error(`review submission ${sid}: Apple still answers "not ready to be submitted yet" after ${Math.round(elapsed / 1000)} s and ${attempt} tries ` - + `(a build attached a moment ago is still being prepared for review). Nothing else changed: re-run the same command in a few minutes; it reuses submission ${sid}.\n` + + `(a build attached a moment ago is still being prepared for review; no item is left REJECTED). Nothing else changed: re-run the same command in a few minutes; it reuses submission ${sid}.\n` + `Last answer: ${(e as Error).message}`); } - log(tag, `review submission ${sid}: not ready to be submitted yet (try ${attempt}, ${Math.round(elapsed / 1000)} s so far, gives up after ${Math.round(t.timeoutMs / 60_000)} min); next try in ${Math.round(delay / 1000)} s`); + log(tag, `review submission ${sid}: not ready to be submitted yet, no item left REJECTED (try ${attempt}, ${Math.round(elapsed / 1000)} s so far, gives up after ${Math.round(t.timeoutMs / 60_000)} min); next try in ${Math.round(delay / 1000)} s`); for (let left = delay; left > 0;) { const step = Math.min(left, t.heartbeatMs); await t.sleep(step); diff --git a/cli/data/flags.json b/cli/data/flags.json index c081376..f328f22 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -92,6 +92,7 @@ "apple iap-submit": [ "new-version", "no-submit", + "resolve-iaps", "with-version" ], "apple listing": [ @@ -155,6 +156,7 @@ "build", "new-version", "no-submit", + "resolve-iaps", "version", "with-version" ], @@ -826,6 +828,7 @@ "release-date", "remove", "render", + "resolve-iaps", "resume", "scenes", "scheme", @@ -945,7 +948,7 @@ "storebound apple iap-review [--dry]" ], "apple iap-submit": [ - "storebound apple iap-submit [--only id1,id2] [--with-version] [--new-version] [--no-submit] [--dry] [--yes] [--json]" + "storebound apple iap-submit [--only id1,id2] [--with-version] [--new-version] [--resolve-iaps] [--no-submit] [--dry] [--yes] [--json]" ], "apple listing": [ "storebound apple listing --version 1.0.0 [--build 3] [--include-drafts] [--dry] [--json]", @@ -979,7 +982,7 @@ "storebound apple release --manual-release [--version 1.2.0] [--yes]" ], "apple resubmit": [ - "storebound apple resubmit --build [--version 1.0.0] [--dry] [--yes] [--json]" + "storebound apple resubmit --build [--version 1.0.0] [--resolve-iaps] [--dry] [--yes] [--json]" ], "apple review-details": [ "storebound apple review-details [--contact-from ] [--dry]" diff --git a/cli/lib/review-submit.ts b/cli/lib/review-submit.ts index 1a42422..8064bdf 100644 --- a/cli/lib/review-submit.ts +++ b/cli/lib/review-submit.ts @@ -61,7 +61,30 @@ export interface SubmitState { choice?: SubmissionChoice; } -export interface OpenSub { id: string; state: string; items: { kind: string; versionId: string; state: string }[] } +export interface OpenSub { id: string; state: string; items: { id?: string; kind: string; versionId: string; state: string }[] } + +/** Which REJECTED items of a submission back from App Review a run marks resolved (PATCH reviewSubmissionItems + * resolved: true) before it submits. Apple answers every submit with 409 "Version is not ready to be submitted yet" + * while one is left (seen live for 25 minutes after an Invalid Binary rejection; retrying never helps). + * version: the appStoreVersion item (apple resubmit attaches a new build); iaps: in-app purchase, subscription and + * subscription group items (only on --resolve-iaps: the owner fixed what App Review asked for). */ +export interface ResolveScope { version: boolean; iaps: boolean } + +/** The REJECTED items `scope` covers. Pure. */ +export function toResolve(items: T[], scope?: ResolveScope): T[] { + if (!scope) return []; + return items.filter(i => i.state === 'REJECTED' && (i.kind === 'appStoreVersion' ? scope.version : scope.iaps)); +} + +/** How to resolve a REJECTED item a run left alone. Pure. */ +export function resolveHint(kind: string): string { + return kind === 'appStoreVersion' + ? 'attach a fixed build and mark it resolved: storebound apple resubmit --build ' + : 'fix what App Review asked for, then mark it resolved with --resolve-iaps (storebound apple resubmit --build --resolve-iaps, or apple iap-submit --resolve-iaps)'; +} + +/** What the plan does with an empty draft next to the reused submission. */ +export const EMPTY_DRAFT = 'empty draft (harmless; App Store Connect keeps it, not cancellable)'; export type SubmissionChoice = | { action: 'reuse'; sub: OpenSub; why: string; strayDrafts: OpenSub[] } | { action: 'busy'; sub: OpenSub; why: string } @@ -221,16 +244,20 @@ export function labelOf(s: SubmitState, versionId: string, kind: string): string } /** The dry-run table of `apple resubmit`: build, submission, every item (in it already or added), submit. Pure. */ -export function resubmitRows(s: SubmitState, plan: SubmitPlan, build: BuildStep): PlanRow[] { +export function resubmitRows(s: SubmitState, plan: SubmitPlan, build: BuildStep, resolve?: ResolveScope): PlanRow[] { const rows: PlanRow[] = [{ step: 'build', what: `build ${build.number}`, now: build.current ? `attached: ${build.current}` : 'none attached', action: build.action === 'refuse' ? `REFUSE: ${build.reason}` : build.reason }]; const ch = s.choice; if (ch?.action === 'busy') rows.push({ step: 'submission', what: ch.sub.id, now: ch.sub.state, action: 'nothing to do until App Review finishes' }); else if (ch?.action === 'reuse') rows.push({ step: 'submission', what: ch.sub.id, now: ch.sub.state, action: ch.sub.state === 'UNRESOLVED_ISSUES' ? 'reuse (resubmit, no new draft)' : 'reuse the draft' }); else rows.push({ step: 'submission', what: 'new draft', now: '-', action: 'create (no open submission)' }); - if (ch && ch.action !== 'create') for (const it of ch.sub.items) rows.push({ step: 'item', what: labelOf(s, it.versionId, it.kind), now: it.state, action: 'in it already' }); + if (ch && ch.action !== 'create') { + const marked = new Set(ch.action === 'reuse' ? toResolve(ch.sub.items, resolve) : []); + for (const it of ch.sub.items) rows.push({ step: 'item', what: labelOf(s, it.versionId, it.kind), now: it.state, + action: marked.has(it) ? 'in it already; mark resolved' : it.state === 'REJECTED' ? `REJECTED, left: ${resolveHint(it.kind)}` : 'in it already' }); + } for (const it of plan.items) rows.push({ step: 'item', what: it.label, now: '-', action: 'add' }); - if (ch?.action === 'reuse') for (const d of ch.strayDrafts) rows.push({ step: 'stray draft', what: d.id, now: `${d.state}, ${d.items.length} item(s)`, action: d.items.length ? 'left as it is (named)' : 'canceled if storebound created it' }); - if (ch?.action !== 'busy') rows.push({ step: 'submit', what: 'PATCH submitted: true', now: '-', action: 'owner step (--yes); retried while Apple says "not ready yet"' }); + if (ch?.action === 'reuse') for (const d of ch.strayDrafts) rows.push({ step: 'stray draft', what: d.id, now: `${d.state}, ${d.items.length} item(s)`, action: d.items.length ? 'left as it is (named)' : `left as it is: ${EMPTY_DRAFT}` }); + if (ch?.action !== 'busy') rows.push({ step: 'submit', what: 'PATCH submitted: true', now: '-', action: 'owner step (--yes); retried while Apple says "not ready yet" and no item is left REJECTED' }); return rows; } diff --git a/docs/api-coverage.md b/docs/api-coverage.md index d124181..39b11ec 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -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 | 198 | 166 | +| App Store Connect API 4.5.1 | 1303 | 826 | 477 | 199 | 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 | @@ -1306,7 +1306,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | **reviewSubmissionItems** (3) | | | | | | `reviewSubmissionItems_createInstance` | POST | `/v1/reviewSubmissionItems` | write (dry by default) | apple/submission, lib/cpp-events, lib/review-submit | | `reviewSubmissionItems_deleteInstance` | DELETE | `/v1/reviewSubmissionItems/{id}` | owner only (typed confirm) | | -| `reviewSubmissionItems_updateInstance` | PATCH | `/v1/reviewSubmissionItems/{id}` | write (dry by default) | | +| `reviewSubmissionItems_updateInstance` | PATCH | `/v1/reviewSubmissionItems/{id}` | write (dry by default) | apple/submission | | **reviewSubmissions** (6) | | | | | | `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 | diff --git a/docs/idempotency.md b/docs/idempotency.md index e8a3bfc..cd88de5 100644 --- a/docs/idempotency.md +++ b/docs/idempotency.md @@ -55,7 +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 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) left as is (an empty draft is not cancellable, harmless, reused later); REJECTED items in scope marked resolved (`resolved: true` is idempotent); 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 | diff --git a/docs/pitfalls.md b/docs/pitfalls.md index c38abd4..478c4b0 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -9,7 +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 ` (build by number, plan table, `--yes`); `apple status` shows the UNRESOLVED_ISSUES items and the command. `tests/resubmit.test.ts`. | +| 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". That 409 lasted 25 minutes with a VALID, APP_STORE_ELIGIBLE build attached and the console's Resubmit / Add for Review disabled: the appStoreVersion item was still REJECTED. `PATCH /v1/reviewSubmissionItems/{id}` `{"data":{"type":"reviewSubmissionItems","id":...,"attributes":{"resolved":true}}}` → 200, item READY_FOR_REVIEW, then the submit passed. An empty READY_FOR_REVIEW draft cannot be canceled (409 "Resource is not in cancellable state"). | `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 left as is (not cancellable, harmless) and the named one used; REJECTED items are marked resolved before the submit (`resolveRejected`: the version item in `apple resubmit`, IAP / subscription items with `--resolve-iaps`); `submitWithRetry` re-reads the items on "not ready", stops at once naming a REJECTED item out of scope and how to resolve it, and backs off (15 s, 30 s, 60 s, 2 min, heartbeat, 10 min limit) only when none is left; `apple status` labels an empty draft harmless and treats its cancel 409 as such; new `apple resubmit --build ` (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`. | diff --git a/skills/appstore-first-release/SKILL.md b/skills/appstore-first-release/SKILL.md index 3a90ce1..53ae745 100644 --- a/skills/appstore-first-release/SKILL.md +++ b/skills/appstore-first-release/SKILL.md @@ -93,8 +93,10 @@ answers the compliance facts no file proves, all in one batch at the start. - 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 ` (dry run with the plan table), and after the owner's yes `--build --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. + draft), adds only missing items, marks the REJECTED version item resolved (Apple refuses every submit with "not + ready to be submitted yet" until it is; IAP items with `--resolve-iaps`) and resubmits, retrying only while + nothing is left REJECTED (pitfall 30, `review-rejections`). `apple iap-submit` reuses it the same way. An empty + draft (0 items) cannot be canceled and is harmless. - 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. diff --git a/skills/review-rejections/SKILL.md b/skills/review-rejections/SKILL.md index ce2ce38..bf928e0 100644 --- a/skills/review-rejections/SKILL.md +++ b/skills/review-rejections/SKILL.md @@ -53,10 +53,16 @@ in-app purchases). Resubmit that same submission; never create a second one next 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 ` (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. + submission reused, every item in it already or added, which REJECTED items are marked resolved, and the submit. 4. The owner's yes, then `storebound apple resubmit --build --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). + items go in, the REJECTED version item is marked resolved (`PATCH reviewSubmissionItems/{id}` `resolved: true`), + then it submits. Apple answers every submit with "Version is not ready to be submitted yet" (and the console's + Resubmit button stays disabled) while any item is still REJECTED; the command reads the items again on that + answer and stops at once naming the item instead of retrying. A REJECTED in-app purchase or subscription item is + marked resolved only with `--resolve-iaps`, once what App Review asked for is fixed. With nothing left REJECTED, + "not ready" is retried with backoff (normal for a few minutes after a build is attached; it gives up after + 10 minutes and a re-run continues). + An empty draft next to the submission (0 items) cannot be canceled and is harmless: leave it. 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) diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index b1f8d84..c160061 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -399,11 +399,25 @@ that fails without the fix (see CONTRIBUTING.md). 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 ` (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. +- **Also seen (same day):** with build 2 VALID, APP_STORE_ELIGIBLE and attached, every `PATCH reviewSubmissions/{id} + submitted: true` answered 409 STATE_ERROR "Version is not ready to be submitted yet, please try again later" for + 25 minutes, and the console's Resubmit and Add for Review buttons were disabled. Retrying never helps: the + appStoreVersion item was still REJECTED. `PATCH /v1/reviewSubmissionItems/{item id}` with + `{"data":{"type":"reviewSubmissionItems","id":"","attributes":{"resolved":true}}}` answered 200, the item + went to READY_FOR_REVIEW, and the submit then passed (WAITING_FOR_REVIEW). The 7 IAP items were READY_FOR_REVIEW. + An empty READY_FOR_REVIEW draft (0 items) cannot be canceled: `canceled: true` answers 409 "Resource is not in + cancellable state". It is harmless; leave it (a later run reuses it). +- **Do:** upload the fixed build, `storebound apple status` (shows the submission, its items, which are REJECTED and + not resolved, and the command), then `storebound apple resubmit --build ` (dry run, plan table with "mark + resolved" rows) and after the owner's yes `--yes`: it attaches the build, marks the REJECTED version item resolved, + then submits. REJECTED IAP / subscription items are marked resolved only with `--resolve-iaps` (after fixing what + App Review asked for). Right after a build is attached, "not ready yet" with nothing left REJECTED is retried with + backoff (up to 10 min) and a re-run continues where it stopped. Never offer a cancel command for an empty draft. - **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`. + it), a draft created by the same run is left as is (not cancellable) and the named one used; `resolveRejected` / + `toResolve` mark REJECTED items in scope resolved before the submit; `submitWithRetry` (cli/apple/submission.ts) + re-reads the items on "not ready", resolves those in scope and stops at once (naming the item and how to resolve + it) when one out of scope is left, backing off only when none is; `apple status` labels an empty draft "empty draft + (harmless; App Store Connect keeps it, not cancellable)" and treats the cancel 409 as harmless; the fake ASC blocks + the submit while an item is REJECTED and refuses to cancel an empty draft; `tests/resubmit.test.ts`. diff --git a/tests/helpers/fake-stores.ts b/tests/helpers/fake-stores.ts index 794bc0a..58dd6a2 100644 --- a/tests/helpers/fake-stores.ts +++ b/tests/helpers/fake-stores.ts @@ -262,9 +262,20 @@ export class FakeAsc { 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)) { + // seen live (2026-10-12): a REJECTED item that is not marked resolved blocks every submit with this 409, and an + // empty draft cannot be canceled + const items = this.children(self.id, 'reviewSubmissionItems'); + if (attrs.submitted && items.some(it => it.attributes.state === 'REJECTED')) + return json({ errors: [{ status: '409', code: 'STATE_ERROR', title: 'The request cannot be fulfilled because of the state of another resource.', detail: 'Version is not ready to be submitted yet, please try again later' }] }, 409); + if (attrs.canceled && self.attributes.state === 'READY_FOR_REVIEW' && !items.length) + return json({ errors: [{ status: '409', code: 'STATE_ERROR', title: 'The request cannot be fulfilled because of the state of another resource.', detail: 'Resource is not in cancellable state' }] }, 409); // 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 if (self.type === 'reviewSubmissionItems' && attrs.resolved === true) { + // marking a REJECTED item resolved puts it back to READY_FOR_REVIEW (seen live) + if (self.attributes.state === 'REJECTED') self.attributes.state = 'READY_FOR_REVIEW'; + self.attributes.resolved = true; } else Object.assign(self.attributes, attrs); return json({ data: this.view(self) }); } diff --git a/tests/resubmit.test.ts b/tests/resubmit.test.ts index 53d38bc..28533a5 100644 --- a/tests/resubmit.test.ts +++ b/tests/resubmit.test.ts @@ -3,6 +3,8 @@ // "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. +// Seen live the same day: a REJECTED item blocks every submit with 409 "not ready yet" until it is marked resolved +// (PATCH reviewSubmissionItems resolved: true), and an empty draft cannot be canceled ("not in cancellable state"). 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'; @@ -42,7 +44,7 @@ const CONFIG: ShipConfig = { /** 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 { +function rejected(o: { pro?: boolean; ver?: string; gems?: string } = {}): 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 } }); @@ -51,8 +53,8 @@ function rejected(o: { pro?: boolean } = {}): void { 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' } }); + s.asc.put({ type: 'reviewSubmissionItems', id: 'it-ver', attributes: { state: o.ver ?? 'REJECTED' }, rels: { reviewSubmission: 'rs-old', appStoreVersion: 'ver-1' } }); + s.asc.put({ type: 'reviewSubmissionItems', id: 'it-gems', attributes: { state: o.gems ?? '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' } }); } @@ -90,28 +92,36 @@ describe('apple resubmit --build ', () => { 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+app version 1\.0\.0\s+REJECTED\s+in it already; mark resolved/); 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(JSON.parse(j.out)).toMatchObject({ dryRun: true, build: { action: 'attach', id: 'b-8' }, submission: { action: 'reuse', sub: { id: 'rs-old' } }, blockers: [], + resolve: [{ item: 'it-ver', kind: 'appStoreVersion', what: 'app version 1.0.0' }] }); 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 () => { + test('--yes: build attached, rs-old reused (no new draft), only the missing IAP added, the REJECTED version item marked resolved, submit retried after "not ready"', async () => { rejected(); + // an empty draft next to it (left by an earlier run): App Store Connect cannot cancel it, so it is left as is + s.asc.put({ type: 'reviewSubmissions', id: 'rs-empty', attributes: { platform: 'IOS', state: 'READY_FOR_REVIEW' }, rels: { app: APP } }); 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', + expect(shown(a)).toEqual(['PATCH /v1/appStoreVersions/ver-1/relationships/build', 'POST /v1/reviewSubmissionItems', 'PATCH /v1/reviewSubmissionItems/it-ver', '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']]); + // the exact request shape that worked live + expect(s.calls.find(c => c.method === 'PATCH' && c.path === '/v1/reviewSubmissionItems/it-ver')!.body).toEqual( + { data: { type: 'reviewSubmissionItems', id: 'it-ver', attributes: { resolved: true } } }); + expect(r.out).toContain('app version 1.0.0: marked resolved (item it-ver)'); + expect(r.out).toContain('rs-empty: empty draft (harmless; App Store Connect keeps it, not cancellable)'); + expect(s.asc.all('reviewSubmissions').map(x => [x.id, x.attributes.state])).toEqual([['rs-old', 'WAITING_FOR_REVIEW'], ['rs-empty', 'READY_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('not ready to be submitted yet, no item left REJECTED (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; @@ -133,6 +143,27 @@ describe('apple resubmit --build ', () => { expect(Math.max(...slept)).toBeLessThanOrEqual(submitTiming.heartbeatMs); }); + test('a REJECTED IAP item is left without --resolve-iaps: one submit, then a stop naming it (no blind retry); --resolve-iaps resolves it', async () => { + rejected({ gems: 'REJECTED', pro: true }); + const p = project(CONFIG); + const plan = await capture(() => appleResubmit(p.ctx(['--build', '8']))); + expect(plan.out).toMatch(/item\s+com\.example\.notes\.gems\s+REJECTED\s+REJECTED, left: .*--resolve-iaps/); + const a = s.calls.length; + await expect(appleResubmit(p.ctx(['--build', '8', '--yes']))).rejects.toThrow(/still REJECTED[\s\S]*com\.example\.notes\.gems \(item it-gems\): .*--resolve-iaps/); + expect(shown(a)).toEqual(['PATCH /v1/appStoreVersions/ver-1/relationships/build', 'PATCH /v1/reviewSubmissionItems/it-ver', 'PATCH /v1/reviewSubmissions/rs-old']); + expect(slept).toEqual([]); + expect(s.asc.res.get('it-gems')!.attributes.state).toBe('REJECTED'); + expect(s.asc.res.get('rs-old')!.attributes.state).toBe('UNRESOLVED_ISSUES'); + // with the flag: the plan says so, and the IAP item is marked resolved before the submit + const plan2 = await capture(() => appleResubmit(p.ctx(['--build', '8', '--resolve-iaps']))); + expect(plan2.out).toMatch(/item\s+com\.example\.notes\.gems\s+REJECTED\s+in it already; mark resolved/); + const b = s.calls.length; + const r = await capture(() => appleResubmit(p.ctx(['--build', '8', '--resolve-iaps', '--yes']))); + expect([r.exit ?? 0, r.err]).toEqual([0, '']); + expect(shown(b)).toEqual(['PATCH /v1/reviewSubmissionItems/it-gems', 'PATCH /v1/reviewSubmissions/rs-old']); + expect(s.asc.res.get('rs-old')!.attributes.state).toBe('WAITING_FOR_REVIEW'); + }); + 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'; @@ -148,7 +179,7 @@ describe('apple resubmit --build ', () => { 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(); + rejected({ ver: 'READY_FOR_REVIEW' }); // the version item was resolved already s.faults.push({ ...NOT_READY, times: 1 }); const p = project(CONFIG); const a = s.calls.length; @@ -165,17 +196,30 @@ describe('apple iap-submit with a submission back from App Review', () => { expect(again.out).toContain('nothing to submit'); }); - test('every item in it already: resubmitted as it is (the submit alone)', async () => { + test('a REJECTED version item: a warning in the plan, one submit, then a stop that points to apple resubmit (no blind retry)', async () => { rejected({ pro: true }); const p = project(CONFIG); + const dry = await capture(() => appleIapSubmit(p.ctx(['--with-version', '--dry']))); + expect(dry.err).toContain('app version 1.0.0 is REJECTED in rs-old: Apple takes no submit while it is not resolved; attach a fixed build and mark it resolved: storebound apple resubmit --build '); + const a = s.calls.length; + await expect(appleIapSubmit(p.ctx(['--with-version', '--yes']))) + .rejects.toThrow(/app version 1\.0\.0 \(item it-ver\): attach a fixed build and mark it resolved: storebound apple resubmit --build /); + expect(shown(a)).toEqual(['PATCH /v1/reviewSubmissions/rs-old']); + expect(slept).toEqual([]); + expect(s.asc.res.get('it-ver')!.attributes.state).toBe('REJECTED'); + }); + + test('every item in it already: resubmitted as it is (the submit alone)', async () => { + rejected({ pro: true, ver: 'READY_FOR_REVIEW' }); + 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(); + test('a 409 ITEM_PART_OF_ANOTHER_SUBMISSION (list missed rs-old): clear message, own empty draft left (not cancellable), rs-old used', async () => { + rejected({ ver: 'READY_FOR_REVIEW' }); // 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); @@ -186,9 +230,9 @@ describe('apple iap-submit with a submission back from App Review', () => { 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(w).toEqual(['POST /v1/reviewSubmissions', 'POST /v1/reviewSubmissionItems', 'POST /v1/reviewSubmissionItems', 'PATCH /v1/reviewSubmissions/rs-old']); + expect(created.attributes.state).toBe('READY_FOR_REVIEW'); + expect(r.out).toContain(`draft ${created.id} created by this run was not used: empty draft (harmless; App Store Connect keeps it, not cancellable)`); expect(s.asc.res.get('rs-old')!.attributes.state).toBe('WAITING_FOR_REVIEW'); }); @@ -213,6 +257,18 @@ describe('apple status', () => { 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 '); + expect(r.out).toMatch(/app version 1\.0\.0\s+REJECTED \(not resolved/); + }); + + test('an empty draft is labelled harmless; --cancel-submission on it answers 409 "not in cancellable state", left as is', async () => { + s.asc.put({ type: 'reviewSubmissions', id: 'rs-empty', attributes: { platform: 'IOS', state: 'READY_FOR_REVIEW' }, rels: { app: APP } }); + const p = project(CONFIG); + const r = await capture(() => appleStatus(p.ctx([]))); + expect(r.out).toContain('review submission rs-empty READY_FOR_REVIEW: empty draft (harmless; App Store Connect keeps it, not cancellable)'); + const c = await capture(() => appleStatus(p.ctx(['--cancel-submission', 'rs-empty', '--yes']))); + expect([c.exit ?? 0, c.err]).toEqual([0, '']); + expect(c.out).toContain('rs-empty is not cancellable'); + expect(s.asc.res.get('rs-empty')!.attributes.state).toBe('READY_FOR_REVIEW'); }); });