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

This commit is contained in:
CRTkafa 2026-10-12 10:45:05 +08:00
parent 0da5ee9d01
commit 1724296e59
17 changed files with 291 additions and 78 deletions

View file

@ -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<string>({ 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<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;
/** 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<void> {
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<void> {
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<string[]> {
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<void> {
/** 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<void> {
const t = submitTiming;
const start = t.now();
const label = o.label ?? ((it: SubItem) => `${it.kind} ${it.versionId}`);
const tried = new Set<string>();
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);