162 lines
14 KiB
TypeScript
162 lines
14 KiB
TypeScript
// 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] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--first-release] [--dry] [--yes] [--json]
|
|
// The platform: --platform, else the one with a rejected version or an UNRESOLVED_ISSUES submission (both: asks).
|
|
// 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. 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.
|
|
// Without --yes this is a dry run with the plan table. Submitting an UPDATE of an app that is already on sale (a version
|
|
// of the platform is or was READY_FOR_SALE) is an agent step: --yes sends it, no owner gate, also in a pipeline. The
|
|
// first-ever submission of an app is the owner's: with --yes it is exit 3 OWNER_STEP unless the owner approved it
|
|
// (--first-release, or bypass mode); cli/lib/release-kind.ts. Not offered through MCP.
|
|
// `storebound apple submit --build <n>` (cli/apple/submit.ts) runs the same steps for a first submission: the version
|
|
// is --version, else the one an open submission holds, else the editable one (Prepare for Submission or rejected).
|
|
// Under `serve` (the desktop's owner action) the plan table is printed next to the --json result.
|
|
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, 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';
|
|
import { explain409, isScreenshotProcessing409, pendingScreenshots, updateLedger, versionScreenshotRows } from './screenshot-health';
|
|
import { openSubmissions } from './submission';
|
|
import { ensureGcSwitch, gameCenterInUse, gcSwitchPlan, readGcSwitch } from './game-center';
|
|
import { shipNoop, shipPhase, shipReport, shipRun } from '../lib/ship-step';
|
|
import { consoleUrl } from '../lib/console-links';
|
|
import { appleReleaseKind, enforceFirstRelease } from '../lib/release-kind';
|
|
import { allVersions, parsePlatform, pickPlatform, platformLabel, platformOf, versionsPath } from './platform';
|
|
|
|
/** App Store version states a first submission may send: the editable version (Prepare for Submission, a rejected
|
|
* one, or one already Ready for Review in a draft). */
|
|
export const SUBMITTABLE_APP = new Set(['PREPARE_FOR_SUBMISSION', 'READY_FOR_REVIEW', ...REJECTED_APP]);
|
|
/** App Store version states after a submission went through: a re-run of `apple submit` with the same build has nothing to do. */
|
|
export const SUBMITTED_APP = new Set(['WAITING_FOR_REVIEW', 'IN_REVIEW', 'PENDING_DEVELOPER_RELEASE', 'PENDING_APPLE_RELEASE', 'PROCESSING_FOR_DISTRIBUTION', 'READY_FOR_DISTRIBUTION', 'READY_FOR_SALE']);
|
|
|
|
export async function run(ctx: Ctx): Promise<void> { await submitVersion(ctx, 'resubmit'); }
|
|
|
|
/** apple resubmit (after a rejection) and apple submit (a version to App Review), with a build picked by number. */
|
|
export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Promise<void> {
|
|
const TAG = mode;
|
|
const { config, root } = ctx.loaded;
|
|
const a = ctx.args;
|
|
const number = a.required('build', 'n', 'the build number you just uploaded, never "the latest"; storebound apple testflight status lists them');
|
|
if (!/^\d+(\.\d+)*$/.test(number)) throw new Error(`--build ${number}: a build number like 42 or 1.0.3 (the one 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();
|
|
shipReport({ build: number, version: a.get('version'), url: consoleUrl('asc.app-review') });
|
|
shipPhase('verify', `reading the App Store versions and build ${number}`);
|
|
// the ship-step contract: the version already went to App Review with this build: nothing to do (exit 7)
|
|
if (mode === 'submit' && shipRun()) {
|
|
const want0 = a.get('version');
|
|
const flag0 = parsePlatform(a.get('platform'));
|
|
const sent = (await allVersions(c, appId)).filter((x: J) => (!want0 || x.attributes.versionString === want0) && (!flag0 || platformOf(x) === flag0)
|
|
&& SUBMITTED_APP.has(String(x.attributes.appVersionState ?? x.attributes.appStoreState)));
|
|
for (const x of sent) {
|
|
const att = (await c.maybe(`/v1/appStoreVersions/${x.id}/build`))?.data;
|
|
if (att && String(att.attributes?.version ?? '') === number) {
|
|
const st = String(x.attributes.appVersionState ?? x.attributes.appStoreState);
|
|
shipNoop({ version: String(x.attributes.versionString), build: number, state: st });
|
|
done(TAG, `version ${String(x.attributes.versionString)} with build ${number} is ${st} already: nothing sent`);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
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 platform: --platform, else the one whose version (or UNRESOLVED_ISSUES submission) this needs; both -> ask
|
|
const stateOf = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState;
|
|
const want = a.get('version');
|
|
const flag = parsePlatform(a.get('platform'));
|
|
let platform = flag;
|
|
if (!platform) {
|
|
const every = await allVersions(c, appId);
|
|
const back = new Set((await c.all(`/v1/apps/${appId}/reviewSubmissions?filter[state]=UNRESOLVED_ISSUES&limit=50`)).map((s: J) => String(s.attributes?.platform ?? 'IOS')));
|
|
const fits = (x: J) => (!want || x.attributes.versionString === want)
|
|
&& ((mode === 'submit' ? SUBMITTABLE_APP : REJECTED_APP).has(stateOf(x)) || back.has(platformOf(x)));
|
|
platform = pickPlatform(every, fits, undefined, mode === 'submit' ? `a version to submit${want ? ` (${want})` : ''}` : `a rejected version${want ? ` (${want})` : ''}`);
|
|
}
|
|
const versions = await c.all(versionsPath(appId, platform));
|
|
const open = await openSubmissions(c, appId, undefined, platform);
|
|
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 pl = platformLabel(platform);
|
|
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) => (mode === 'submit' ? SUBMITTABLE_APP : REJECTED_APP).has(stateOf(x)));
|
|
if (!v) throw new Error(want ? `no App Store version ${want} on ${pl}`
|
|
: mode === 'submit' ? `no ${pl} App Store version to submit (none in Prepare for Submission or rejected): storebound apple listing --version x.y.z first`
|
|
: `no rejected ${pl} App Store version and no ${pl} review submission with UNRESOLVED_ISSUES: nothing to resubmit (storebound apple status; another platform: --platform)`);
|
|
if (mode === 'submit' && !SUBMITTABLE_APP.has(stateOf(v)) && !held.has(v.id)) throw new Error(`App Store version ${String(v.attributes.versionString)} (${pl}) is ${stateOf(v)}: only a version in Prepare for Submission (or a rejected one) can be submitted`);
|
|
const versionString = String(v.attributes.versionString);
|
|
if (!a.has('json')) log(TAG, `app version ${versionString} (${pl}): ${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]=${platform}`))[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, platform });
|
|
const plan = planIapSubmission(state, ids, { withVersion: true, appVersionId: v.id, platform });
|
|
// --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');
|
|
const kind = appleReleaseKind(versions.map((x: J) => ({ versionString: String(x.attributes.versionString), state: stateOf(x) })));
|
|
// Game Center: a build with the entitlement needs the app's Game Center detail and the version's switch, else the
|
|
// submission answers 409 STATE_ERROR.BUILD_INDICATES_GAME_CENTER_ENABLED (seen live); the same upsert as apple game-center
|
|
const gcUse = gameCenterInUse(config, root);
|
|
const gc = gcUse.inUse ? await readGcSwitch(c, appId, v) : null;
|
|
const gcPlan = gc ? gcSwitchPlan(gc) : [];
|
|
if (a.has('json')) {
|
|
printJson({ dryRun: dry, releaseKind: kind.kind, gameCenter: gcPlan, version: { id: v.id, versionString, platform, state: stateOf(v) }, build, submission: state.choice ?? null, resolve: marks, rows, skipped: plan.skipped, blockers });
|
|
// the desktop's owner dialog shows the output text: the plan table goes with the result there
|
|
if (process.env.STOREBOUND_MACHINE === '1') { console.log(formatRows(rows)); for (const x of blockers) warn(TAG, x); }
|
|
if (blockers.length) process.exitCode = 1;
|
|
else if (state.choice?.action === 'busy') shipNoop({ version: versionString, state: stateOf(v) });
|
|
if (dry || blockers.length || state.choice?.action === 'busy') return;
|
|
} else {
|
|
console.log(formatRows(rows));
|
|
log(TAG, `${kind.kind === 'update' ? 'update of an app on sale' : 'FIRST release of this app (the owner approves it once: --first-release)'}: ${kind.why}`);
|
|
for (const l of gcPlan) log(TAG, `${dry ? 'would ' : ''}${l} (Game Center: ${gcUse.why})`);
|
|
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') { shipNoop({ version: versionString, state: stateOf(v) }); done(TAG, `nothing to ${mode}: ${state.choice.why}`); return; }
|
|
if (dry) {
|
|
done(TAG, a.has('dry') ? 'dry run: nothing sent' : `dry run (no --yes): nothing sent. ${kind.kind === 'update' ? 'This is an update of an app on sale: run it again with --yes, no owner step' : `${mode === 'submit' ? 'Submitting' : 'Resubmitting'} the first release is the owner's step: after the owner's yes, run it again with --yes --first-release`}`);
|
|
return;
|
|
}
|
|
if (!enforceFirstRelease(ctx, kind, { store: 'apple', what: 'the first submission to App Review', consoleUrl: consoleUrl('asc.version') })) return;
|
|
|
|
shipPhase('submit', `${mode === 'submit' ? 'submitting' : 'resubmitting'} ${versionString} with build ${number}`);
|
|
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}`);
|
|
}
|
|
if (gc && gcPlan.length) await ensureGcSwitch(c, gc, TAG);
|
|
try {
|
|
await submitPlanned(ctx, c, { appId, ids, state, tag: TAG, withVersion: true, appVersionId: v.id, platform, quiet: true, resolve });
|
|
} catch (e) {
|
|
if (!isScreenshotProcessing409(e)) throw e;
|
|
// 409 STATE_ERROR.ASSET_IN_POST_PROCESSING.APP_SCREENSHOT: name every screenshot that is not COMPLETE
|
|
const rows = await versionScreenshotRows(c, v.id);
|
|
const pending = pendingScreenshots(rows, updateLedger(root, rows), Date.now());
|
|
throw new Error(`${explain409(pending)}\nLast answer: ${(e as Error).message}`);
|
|
}
|
|
}
|