apple: macOS (tvOS, visionOS) versions are no longer invisible: every version and review submission lookup is per platform; status, listing, listing pull, resubmit, submit, release and screenshots take --platform and otherwise pick the platform that has the version they need (refuse naming both when several do); status, validate, release status, the report and the desktop label versions with their platform; MCP and owner actions get platform; pitfall 77

This commit is contained in:
CRTkafa 2026-10-20 03:00:05 +07:00
parent 1736300784
commit 47dfd372fe
30 changed files with 423 additions and 127 deletions

View file

@ -140,8 +140,9 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
return appId;
}
async function editableVersion(): Promise<J | null> {
const vs = await all(`/v1/apps/${await app()}/appStoreVersions?filter[platform]=IOS&limit=50`);
/** The editable version of one platform (iOS unless named: an app's macOS version has its own state). */
async function editableVersion(platform = 'IOS'): Promise<J | null> {
const vs = await all(`/v1/apps/${await app()}/appStoreVersions?filter[platform]=${platform}&limit=50`);
return vs.find(v => EDITABLE.has(v.attributes.appVersionState ?? v.attributes.appStoreState)) ?? null;
}

View file

@ -26,8 +26,9 @@ import { addItem, draftJournalKey, ensureDraft, leaveUnusedDraft, openSubmission
const TAG = 'iap-submit';
export async function readSubmitState(c: Asc, appId: string, want: string[], o: { appVersionId?: string; also?: string } = {}): Promise<SubmitState> {
const appVersions = (await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`)).map((v: J) => ({ id: v.id, versionString: v.attributes.versionString,
export async function readSubmitState(c: Asc, appId: string, want: string[], o: { appVersionId?: string; also?: string; platform?: string } = {}): Promise<SubmitState> {
const platform = o.platform ?? 'IOS';
const appVersions = (await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=${platform}&limit=50`)).map((v: J) => ({ id: v.id, versionString: v.attributes.versionString,
state: v.attributes.appVersionState ?? v.attributes.appStoreState }));
const iaps: PIap[] = [];
for (const p of await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)) {
@ -45,7 +46,7 @@ export async function readSubmitState(c: Asc, appId: string, want: string[], o:
versions: want.includes(s.attributes.productId) ? versionsOf(await c.all(`/v1/subscriptions/${s.id}/versions?limit=200`)) : [] });
}
// every open review submission: a draft or one back from App Review is reused, never a second one next to it
const open = await openSubmissions(c, appId, o.also);
const open = await openSubmissions(c, appId, o.also, platform);
const choice = chooseSubmission(open, { appVersionId: o.appVersionId ?? appVersions.find((v: { state: string }) => /REJECTED|INVALID_BINARY/.test(v.state))?.id });
const draft: SubmitState['draft'] = choice.action === 'reuse' ? { id: choice.sub.id, state: choice.sub.state, itemVersionIds: choice.sub.items.map(i => i.versionId) } : null;
return { appVersions, iaps, subs, groups, draft, open, choice };
@ -96,6 +97,8 @@ export interface SubmitOpts {
appId: string; ids: string[]; state: SubmitState; tag: string; withVersion?: boolean;
/** the app version that must be in the submission (resubmit) */
appVersionId?: string;
/** the App Store platform of the version and the submission (default iOS) */
platform?: 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) */
@ -116,7 +119,7 @@ export async function submitPlanned(ctx: Ctx, c: Asc, o: SubmitOpts): Promise<vo
const a = ctx.args;
const dry = a.has('dry');
const { state, tag } = o;
const plan = planIapSubmission(state, o.ids, { withVersion: o.withVersion, appVersionId: o.appVersionId });
const plan = planIapSubmission(state, o.ids, { withVersion: o.withVersion, appVersionId: o.appVersionId, platform: o.platform });
const prepare = plan.prepare.map(r => bindIds(r, { app: o.appId }));
const submitting = willSubmit(state, plan);
const busy = state.choice?.action === 'busy';
@ -155,7 +158,7 @@ export async function submitPlanned(ctx: Ctx, c: Asc, o: SubmitOpts): Promise<vo
// already in it, and an item the store says is in there already (409) is not added twice
let sid = state.draft?.id;
let created = false;
if (!sid) ({ id: sid, created } = await ensureDraft(c, o.appId, root, tag));
if (!sid) ({ id: sid, created } = await ensureDraft(c, o.appId, root, tag, o.platform));
try {
for (const r of prepare) {
if (r.path === '/v1/reviewSubmissions') continue;
@ -175,7 +178,7 @@ export async function submitPlanned(ctx: Ctx, c: Asc, o: SubmitOpts): Promise<vo
if (o.retried || e.otherId === '?') throw e;
// use the submission the 409 named: read the state again with it and go once more
log(tag, `using review submission ${e.otherId} instead`);
const again = await readSubmitState(c, o.appId, o.ids, { appVersionId: o.appVersionId ?? plan.appVersion?.id, also: e.otherId });
const again = await readSubmitState(c, o.appId, o.ids, { appVersionId: o.appVersionId ?? plan.appVersion?.id, also: e.otherId, platform: o.platform });
for (const l of choiceLines(again)) log(tag, l);
await submitPlanned(ctx, c, { ...o, state: again, retried: true, quiet: true });
return;

View file

@ -1,5 +1,6 @@
// App Store version + listing text (idempotent; re-run after editing the App Store listing file).
// storebound apple listing --version 1.0.0 [--build 3] [--include-drafts] [--dry] [--json]
// storebound apple listing --version 1.0.0 [--platform ios|macos|tvos|visionos] [--build 3] [--include-drafts] [--dry] [--json]
// The platform: --platform, else the one where that version is editable (both: asks), else the only one / iOS.
// Every run first reads App Store Connect and prints the plan: the version (keep, update the editable one, create, or
// refuse with the reason), the build, and per locale create / update with old -> new per field. --dry stops there
// (read-only); the real run asks in a terminal and applies exactly that plan. Locales marked status=draft or
@ -24,6 +25,7 @@ import { confirm, need } from '../lib/ctx';
import { done, log, printJson, warn } from '../lib/log';
import { ascClient, EDITABLE, type J, noAppInfo } from './asc';
import { stdinIsTTY } from '../lib/tty';
import { allVersions, type AscPlatform, parsePlatform, pickPlatform, platformLabel, versionsPath } from './platform';
export function readAppleCopy(ctx: Ctx) {
const { config, resolve } = ctx.loaded;
@ -38,11 +40,14 @@ async function pull(ctx: Ctx): Promise<void> {
const a = ctx.args;
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
const appId = await c.app();
const versions = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`);
const want = a.get('version');
const flag = parsePlatform(a.get('platform'));
const platform = flag ?? pickPlatform(await allVersions(c, appId), want ? [(x: J) => x.attributes.versionString === want && EDITABLE.has(x.attributes.appVersionState ?? x.attributes.appStoreState), (x: J) => x.attributes.versionString === want] : (x: J) => EDITABLE.has(x.attributes.appVersionState ?? x.attributes.appStoreState), undefined, want ? `version ${want}` : 'an editable version');
const versions = await c.all(versionsPath(appId, platform));
const sorted = [...versions].sort((x: J, y: J) => String(y.attributes.createdDate ?? '').localeCompare(String(x.attributes.createdDate ?? '')));
const v = want ? sorted.find((x: J) => x.attributes.versionString === want) : (await c.editableVersion()) ?? sorted[0];
if (!v) throw new Error(want ? `no iOS version ${want} on App Store Connect` : 'no iOS version on App Store Connect yet');
const v = want ? sorted.find((x: J) => x.attributes.versionString === want) : (await c.editableVersion(platform)) ?? sorted[0];
if (!v) throw new Error(want ? `no ${platformLabel(platform)} version ${want} on App Store Connect` : `no ${platformLabel(platform)} version on App Store Connect yet`);
log('apple', `pulling version ${v.attributes.versionString} (${platformLabel(platform)})`);
const infos = await c.all(`/v1/apps/${appId}/appInfos`);
const live = v.attributes.appVersionState ?? v.attributes.appStoreState;
// the app info that belongs to the version: the editable one while a version is being prepared, else the live one
@ -77,6 +82,8 @@ async function pull(ctx: Ctx): Promise<void> {
/** The plan of one `apple listing --version` run, from read-only GETs. */
export interface AppleListingPlan {
version: string;
/** the App Store platform of the version (IOS, MAC_OS, ...) */
platform: AscPlatform;
versionPlan: VersionPlan;
build: { number: string; id: string | null; current: string | null; action: 'attach' | 'same' | 'refuse'; reason: string } | null;
appInfo: { id: string; state: string; locales: ItemPlan[] };
@ -90,7 +97,7 @@ export interface AppleListingPlan {
type Asc = Awaited<ReturnType<typeof ascClient>>;
async function planListing(ctx: Ctx, c: Asc, appId: string, version: string): Promise<AppleListingPlan> {
async function planListing(ctx: Ctx, c: Asc, appId: string, version: string, platform: AscPlatform): Promise<AppleListingPlan> {
const { config, resolve } = ctx.loaded;
const app = config.app;
const listing = readAppleCopy(ctx);
@ -106,13 +113,13 @@ async function planListing(ctx: Ctx, c: Asc, appId: string, version: string): Pr
else { skipped.push({ key: l, reason: draftReason(st) }); delete listing[l]; }
}
const copyright = app.copyright ?? `${new Date().getFullYear()} ${app.name}`;
const rows = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`);
const versionPlan = planVersion(rows, version, { copyright, releaseType: 'MANUAL' });
const rows = await c.all(versionsPath(appId, platform));
const versionPlan = planVersion(rows, version, { copyright, releaseType: 'MANUAL' }, platform);
let build: AppleListingPlan['build'] = null;
const number = ctx.args.get('build');
if (number) {
const b = (await c.all(`/v1/builds?filter[app]=${appId}&filter[version]=${number}&filter[preReleaseVersion.version]=${version}&filter[preReleaseVersion.platform]=IOS`))[0];
const b = (await c.all(`/v1/builds?filter[app]=${appId}&filter[version]=${number}&filter[preReleaseVersion.version]=${version}&filter[preReleaseVersion.platform]=${platform}`))[0];
const cur = versionPlan.id ? (await c.maybe(`/v1/appStoreVersions/${versionPlan.id}/build`))?.data : null;
const current = cur ? String(cur.attributes?.version ?? cur.id) : null;
build = !b ? { number, id: null, current, action: 'refuse', reason: `build ${version} (${number}) not found (still uploading? check TestFlight)` }
@ -133,14 +140,14 @@ async function planListing(ctx: Ctx, c: Asc, appId: string, version: string): Pr
const changes = (versionPlan.action === 'create' || versionPlan.action === 'update' ? 1 : 0) + (build?.action === 'attach' ? 1 : 0)
+ infoLocs.filter(x => x.action !== 'same').length + (versionLocales ? versionLocales.filter(x => x.action !== 'same').length : Object.keys(wanted).length);
return { version, versionPlan, build, appInfo: { id: info.id, state: String(info.attributes?.state ?? info.attributes?.appStoreState ?? ''), locales: infoLocs },
return { version, platform, versionPlan, build, appInfo: { id: info.id, state: String(info.attributes?.state ?? info.attributes?.appStoreState ?? ''), locales: infoLocs },
versionLocales, wanted, skipped, notes, changes };
}
/** The plan as text lines. Pure. */
export function listingPlanLines(p: AppleListingPlan): string[] {
const v = p.versionPlan;
const out = [`Version (App Store Connect has: ${v.existing.length ? v.existing.map(x => `${x.versionString} ${x.state} ${x.platform}`).join(', ') : 'no iOS version yet'})`];
const out = [`Version ${p.version} (${platformLabel(p.platform)}; App Store Connect has: ${v.existing.length ? v.existing.map(x => `${x.versionString} ${x.state} ${platformLabel(x.platform)}`).join(', ') : `no ${platformLabel(p.platform)} version yet`})`];
out.push(` ${v.action === 'refuse' ? 'REFUSE' : v.action.toUpperCase()}: ${v.reason}`);
for (const ch of v.changes) out.push(` ${ch.field}: ${v.action === 'create' ? short(ch.to) : `${short(ch.from)} -> ${short(ch.to)}`}`);
if (p.build) out.push(`Build: ${p.build.action === 'refuse' ? 'REFUSE: ' : ''}${p.build.reason}`);
@ -186,7 +193,11 @@ export async function run(ctx: Ctx): Promise<void> {
const dry = a.has('dry');
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
const appId = await c.app();
const p = await planListing(ctx, c, appId, version);
// the platform: --platform, else the one where this version string is editable, else the one with an editable
// version; several -> ask (an app's iOS 0.8.7 can be live while its macOS 0.8.7 is rejected)
const isEd = (x: J) => EDITABLE.has(x.attributes.appVersionState ?? x.attributes.appStoreState);
const platform = parsePlatform(a.get('platform')) ?? pickPlatform(await allVersions(c, appId), [(x: J) => x.attributes.versionString === version && isEd(x), isEd], undefined, `an editable version (${version})`);
const p = await planListing(ctx, c, appId, version, platform);
const refused = [p.versionPlan.action === 'refuse' ? p.versionPlan.reason : '', p.build?.action === 'refuse' ? p.build.reason : ''].filter(Boolean);
if (a.has('json')) printJson({ dry, ...p, refused });
else for (const line of listingPlanLines(p)) console.log(line);
@ -200,9 +211,9 @@ export async function run(ctx: Ctx): Promise<void> {
if (vp.action === 'create') {
// look again right before the create (another run, or a lost answer), then the journal for the list lag
const made = await findOrCreate<J>({ what: `App Store version ${version}`, tag: 'apple', unique: true,
find: async () => (await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`))
find: async () => (await c.all(versionsPath(appId, p.platform)))
.find((x: J) => x.attributes.versionString === version && EDITABLE.has(x.attributes.appVersionState ?? x.attributes.appStoreState)) ?? null,
journal: { root, key: `apple:${appId}:version:IOS:${version}`, byId: id => getById(c.asc, `/v1/appStoreVersions/${id}`) as Promise<J | null>, idOf: v => v.id },
journal: { root, key: `apple:${appId}:version:${p.platform}:${version}`, byId: id => getById(c.asc, `/v1/appStoreVersions/${id}`) as Promise<J | null>, idOf: v => v.id },
create: async () => (await c.asc('POST', '/v1/appStoreVersions', { data: { type: 'appStoreVersions',
attributes: Object.fromEntries(Object.entries(vp.attributes)), relationships: { app: { data: { type: 'apps', id: appId } } } } })).data });
versionId = made.value.id;

61
cli/apple/platform.ts Normal file
View file

@ -0,0 +1,61 @@
// App Store platforms of one app record. An app can have an iOS and a macOS (tvOS, visionOS) version at the same time,
// each with its own state and its own review submission (pitfall 77): every version lookup filters by platform, and a
// command that needs one version picks the platform that has it, or asks for --platform when several do.
import type { Asc, J } from './asc';
export type AscPlatform = 'IOS' | 'MAC_OS' | 'TV_OS' | 'VISION_OS';
export const ASC_PLATFORMS: AscPlatform[] = ['IOS', 'MAC_OS', 'TV_OS', 'VISION_OS'];
const FLAG: Record<string, AscPlatform> = { ios: 'IOS', macos: 'MAC_OS', tvos: 'TV_OS', visionos: 'VISION_OS' };
const LABEL: Record<AscPlatform, string> = { IOS: 'iOS', MAC_OS: 'macOS', TV_OS: 'tvOS', VISION_OS: 'visionOS' };
/** The console's URL segment of a platform's version page. */
export const CONSOLE_SEGMENT: Record<AscPlatform, string> = { IOS: 'ios', MAC_OS: 'macos', TV_OS: 'appletvos', VISION_OS: 'visionos' };
/** --platform ios|macos|tvos|visionos (also the API names IOS, MAC_OS, ...) -> the API's platform; undefined when not
* given; throws on anything else. Pure. */
export function parsePlatform(v: string | undefined): AscPlatform | undefined {
if (v == null || v === '') return undefined;
const k = v.toLowerCase().replace(/[_\s-]/g, '');
const p = FLAG[k] ?? FLAG[k.replace(/^appletv/, 'tv')];
if (!p) throw new Error(`--platform: ios | macos | tvos | visionos (got "${v}")`);
return p;
}
/** "iOS", "macOS", ... for any platform string (unknown ones as they are). Pure. */
export const platformLabel = (p: string | undefined | null): string => LABEL[(p ?? 'IOS') as AscPlatform] ?? String(p);
export const platformOf = (v: J): AscPlatform => (v?.attributes?.platform ?? 'IOS') as AscPlatform;
export const stateOf = (v: J): string => v?.attributes?.appVersionState ?? v?.attributes?.appStoreState ?? '';
/** "0.8.7 (macOS)": a version as people read it. */
export const versionLabel = (v: J): string => `${v?.attributes?.versionString ?? '?'} (${platformLabel(platformOf(v))})`;
/** The appStoreVersions list path of one platform. */
export const versionsPath = (appId: string, platform: AscPlatform, limit = 50): string =>
`/v1/apps/${appId}/appStoreVersions?filter[platform]=${platform}&limit=${limit}`;
/**
* The platform a command works on. `flag` (--platform) wins. Else the platforms that have a version `needs` accepts:
* one -> that one; several -> an error naming them all (ask for --platform); none -> the only platform the app has
* versions on, else iOS. Pure.
*/
export function pickPlatform(versions: J[], needs: ((v: J) => boolean) | ((v: J) => boolean)[], flag: AscPlatform | undefined, what: string): AscPlatform {
if (flag) return flag;
// several tests: the first one any version passes decides (e.g. "this version string, editable", then "editable")
if (Array.isArray(needs)) {
for (const n of needs) if (versions.some(n)) return pickPlatform(versions, n, undefined, what);
return pickPlatform(versions, () => false, undefined, what);
}
const hits = [...new Set(versions.filter(needs).map(platformOf))];
if (hits.length === 1) return hits[0]!;
if (hits.length > 1) {
const which = versions.filter(needs).map(v => `${versionLabel(v)} ${stateOf(v)}`).join(', ');
throw new Error(`${what} on more than one platform (${which}): run it again with --platform ${hits.map(p => Object.keys(FLAG).find(k => FLAG[k] === p)).join(' or --platform ')}`);
}
const all = [...new Set(versions.map(platformOf))];
return all.length === 1 ? all[0]! : 'IOS';
}
/** Every App Store version of the app on every platform (only to pick the platform; the command then reads its
* platform's list with filter[platform]). */
export async function allVersions(c: Asc, appId: string): Promise<J[]> {
return c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=${ASC_PLATFORMS.join(',')}&limit=200`);
}

View file

@ -1,6 +1,7 @@
// Release management for an App Store version.
// storebound apple release --phased on|pause|resume|complete [--version 1.2.0] [--dry] [--yes]
// storebound apple release --manual-release [--version 1.2.0] [--yes]
// Both take --platform ios|macos|tvos|visionos; without it the platform that has the version this needs is used (both: asks).
// Phased release: 7-day automatic rollout to users with automatic updates (1, 2, 5, 10, 20, 50, 100 %); anyone can
// still update by hand from the App Store. Pause holds it (up to 30 days in total), it is NOT a rollback: users who
// updated keep the build. "complete" releases to everyone at once.
@ -12,6 +13,7 @@ import { done, log } from '../lib/log';
import { type PhasedAction, phasedRequest } from '../lib/release';
import { createOnce } from '../lib/upsert';
import { ascClient, type J } from './asc';
import { allVersions, parsePlatform, pickPlatform, platformLabel, versionsPath } from './platform';
export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded;
@ -23,15 +25,20 @@ export async function run(ctx: Ctx): Promise<void> {
const dry = a.has('dry');
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
const appId = await c.app();
const versions = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=20`);
const want = a.get('version');
const state = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState;
// the platform: --platform, else the one with the version this needs (both: asks)
const needs = (x: J): boolean => (!want || x.attributes.versionString === want) && (manual ? state(x) === 'PENDING_DEVELOPER_RELEASE'
: phased === 'on' ? !['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE', 'REPLACED_WITH_NEW_VERSION', 'REMOVED_FROM_SALE'].includes(state(x))
: ['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE', 'PENDING_DEVELOPER_RELEASE', 'PENDING_APPLE_RELEASE'].includes(state(x)));
const platform = parsePlatform(a.get('platform')) ?? pickPlatform(await allVersions(c, appId), want ? [needs, (x: J) => x.attributes.versionString === want] : needs, undefined, manual ? 'a version waiting for developer release' : `a version for phased release ${phased}`);
const versions = await c.all(versionsPath(appId, platform, 20));
const v: J | undefined = want ? versions.find(x => x.attributes.versionString === want)
: manual ? versions.find(x => state(x) === 'PENDING_DEVELOPER_RELEASE')
: phased === 'on' ? versions.find(x => !['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE', 'REPLACED_WITH_NEW_VERSION', 'REMOVED_FROM_SALE'].includes(state(x)))
: versions.find(x => ['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE'].includes(state(x))) ?? versions[0];
if (!v) throw new Error(want ? `no version ${want}` : manual ? 'no version waiting for developer release (PENDING_DEVELOPER_RELEASE)' : 'no matching version');
const tag = `${v.attributes.versionString} (${state(v)})`;
if (!v) throw new Error(`${platformLabel(platform)}: ${want ? `no version ${want}` : manual ? 'no version waiting for developer release (PENDING_DEVELOPER_RELEASE)' : 'no matching version'}`);
const tag = `${v.attributes.versionString} ${platformLabel(platform)} (${state(v)})`;
if (manual) {
if (state(v) !== 'PENDING_DEVELOPER_RELEASE') throw new Error(`${tag}: only an approved version waiting for developer release can be released`);

View file

@ -1,5 +1,6 @@
// 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] [--resolve-iaps] [--dry] [--yes] [--json]
// storebound apple resubmit --build <n> [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--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
@ -25,6 +26,7 @@ import { allSubscriptions } from '../lib/subscriptions';
import { ascClient, type J } from './asc';
import { readSubmitState, submitPlanned } from './iap-submit';
import { openSubmissions } from './submission';
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). */
@ -43,23 +45,33 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
const appId = await c.app();
const ids = [...(config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id), ...allSubscriptions(config.subscriptions).filter(x => sellsOn(config, x.sub, 'apple')).map(x => x.sub.id)];
// the version to resubmit
const versions = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`);
// 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 open = await openSubmissions(c, appId);
const held = new Set(open.filter(s => s.state === 'UNRESOLVED_ISSUES').flatMap(s => s.items.filter(i => i.kind === 'appStoreVersion').map(i => i.versionId)));
const want = a.get('version');
const 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 iOS`
: mode === 'submit' ? 'no App Store version to submit (none in Prepare for Submission or rejected): storebound apple listing --version x.y.z first'
: 'no rejected App Store version and no review submission with UNRESOLVED_ISSUES: nothing to resubmit (storebound apple status)');
if (mode === 'submit' && !SUBMITTABLE_APP.has(stateOf(v)) && !held.has(v.id)) throw new Error(`App Store version ${String(v.attributes.versionString)} is ${stateOf(v)}: only a version in Prepare for Submission (or a rejected one) can be submitted`);
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}: ${stateOf(v)}`);
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]=IOS`))[0];
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)` }
@ -68,8 +80,8 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
: { number, id: b.id, current, action: 'attach', reason: `attach build ${number}${current ? ` instead of ${current}` : ''}` };
if (b && b.attributes.usesNonExemptEncryption == null) warn(TAG, `build ${number} has no export compliance answer yet: storebound apple encryption (Apple refuses the submission without it)`);
const state = await readSubmitState(c, appId, ids, { appVersionId: v.id });
const plan = planIapSubmission(state, ids, { withVersion: true, appVersionId: v.id });
const 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);
@ -77,7 +89,7 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
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, resolve: marks, rows, skipped: plan.skipped, blockers });
printJson({ dryRun: dry, 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;
@ -98,5 +110,5 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
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, resolve });
await submitPlanned(ctx, c, { appId, ids, state, tag: TAG, withVersion: true, appVersionId: v.id, platform, quiet: true, resolve });
}

View file

@ -18,7 +18,8 @@ import { findOrCreate } from '../lib/upsert';
import { pulledChecksum } from '../lib/art-pull';
import { consoleLink } from '../lib/console-links';
import { IPHONE_DUO_DISPLAY, IPHONE_DUO_NOT_IN_API } from '../shots/spec';
import { type Asc, ascClient, keptPrefix, md5, setUpToDate, type J } from './asc';
import { type Asc, ascClient, EDITABLE, keptPrefix, md5, setUpToDate, type J } from './asc';
import { allVersions, parsePlatform, pickPlatform, platformLabel, stateOf, versionLabel } from './platform';
/** Render size key -> ASC screenshotDisplayType (iPhone sets). */
export const DISPLAY: Record<string, string> = { ios69: 'APP_IPHONE_67', ios65: 'APP_IPHONE_65' };
@ -83,8 +84,11 @@ export async function run(ctx: Ctx): Promise<void> {
const only = ctx.args.list('only');
const jobs = ctx.args.num('jobs', 4);
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
const version = await c.editableVersion();
if (!version) throw new Error('no editable App Store version (run apple listing --version x.y.z first)');
// the platform: --platform, else the one with an editable version (both: asks)
const platform = parsePlatform(ctx.args.get('platform')) ?? pickPlatform(await allVersions(c, await c.app()), x => EDITABLE.has(stateOf(x)), undefined, 'an editable version');
const version = await c.editableVersion(platform);
if (!version) throw new Error(`no editable ${platformLabel(platform)} App Store version (run apple listing --version x.y.z first)`);
log('apple', `screenshots for version ${versionLabel(version)}`);
const duoSkip = skippedDuoSets(config.apple);
if (duoSkip) warn('apple', duoSkip.msg);
const langOf = byAppleLocale(config.languages);

View file

@ -1,5 +1,6 @@
// Read-only overview: app record, versions + attached build, recent builds, in-app purchases, review submissions.
// storebound apple status [--json]
// storebound apple status [--platform ios|macos|tvos|visionos] [--json]
// Versions and review submissions of every platform, each labelled (iOS, macOS, ...); --platform shows one.
// And the one write it offers: cancel a review submission (to re-add first IAPs to it from the web UI):
// storebound apple status --cancel-submission <id> [--yes]
// An empty draft (READY_FOR_REVIEW, 0 items) cannot be canceled (409 "Resource is not in cancellable state"); it is
@ -23,6 +24,7 @@ import { type Asc, ascClient, type J } from './asc';
import { notCancellable, readItems, type SubItem } from './submission';
import { capabilityReport, type CapabilityReport, printCapabilityReport } from './signing';
import * as rt from '../lib/rt';
import { allVersions, parsePlatform, pickPlatform, platformLabel, platformOf, versionLabel, versionsPath } from './platform';
async function waitReview(ctx: Ctx, c: Asc, appId: string): Promise<never> {
const want = ctx.args.get('version');
@ -31,12 +33,14 @@ async function waitReview(ctx: Ctx, c: Asc, appId: string): Promise<never> {
let delay: number | null = null;
let last = '';
for (;;) {
const vs = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=10`);
const flag = parsePlatform(ctx.args.get('platform'));
const platform = flag ?? pickPlatform(await allVersions(c, appId), x => (!want || x.attributes.versionString === want) && classifyReviewState(state(x)) === 'pending', undefined, 'a version in review');
const vs = await c.all(versionsPath(appId, platform, 10));
const v = want ? vs.find(x => x.attributes.versionString === want) : vs.find(x => classifyReviewState(state(x)) === 'pending') ?? vs[0];
if (!v) { log('apple', `no version ${want ?? ''} found`); process.exit(2); }
const st = state(v);
const outcome = classifyReviewState(st);
if (st !== last) { log('apple', `${v.attributes.versionString}: ${st}`); last = st; }
if (st !== last) { log('apple', `${versionLabel(v)}: ${st}`); last = st; }
if (outcome === 'approved') { log('apple', 'approved (release it yourself, or it goes out automatically if the release type says so)'); process.exit(WAIT_EXIT.approved); }
if (outcome === 'rejected') { log('apple', `rejected: read the message in ${consoleLink('asc.app-review')}, then the review-rejections skill`); process.exit(WAIT_EXIT.rejected); }
if (outcome === 'not-submitted') { log('apple', 'nothing is waiting for review'); process.exit(2); }
@ -70,15 +74,17 @@ export async function run(ctx: Ctx): Promise<void> {
}
const app = (await c.asc('GET', `/v1/apps/${appId}`)).data;
const versions = await c.all(`/v1/apps/${appId}/appStoreVersions?limit=10&include=build`);
// every platform's versions (an app's macOS version has its own state), or one with --platform
const only = parsePlatform(ctx.args.get('platform'));
const versions = await c.all(only ? `${versionsPath(appId, only, 20)}&include=build` : `/v1/apps/${appId}/appStoreVersions?limit=20&include=build`);
const builds = (await c.asc('GET', `/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=5&include=preReleaseVersion`));
const iaps = await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`);
const subs = await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&limit=20`);
const subs = await c.all(`/v1/reviewSubmissions?filter[app]=${appId}${only ? `&filter[platform]=${only}` : ''}&limit=20`);
let caps: CapabilityReport | { error: string };
try { caps = await capabilityReport(c, config, root); } catch (e) { caps = { error: (e as Error).message.slice(0, 300) }; }
// a submission back from App Review: its items and their states, and how to resubmit it
const unresolved: { id: string; items: SubItem[] }[] = [];
for (const s of subs.filter((x: J) => x.attributes?.state === 'UNRESOLVED_ISSUES')) unresolved.push({ id: s.id, items: await readItems(c, s.id) });
const unresolved: { id: string; platform: string; items: SubItem[] }[] = [];
for (const s of subs.filter((x: J) => x.attributes?.state === 'UNRESOLVED_ISSUES')) unresolved.push({ id: s.id, platform: String(s.attributes?.platform ?? 'IOS'), items: await readItems(c, s.id) });
// an empty draft: App Store Connect keeps it and refuses to cancel it; harmless
const empty = new Set<string>();
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);
@ -89,7 +95,7 @@ export async function run(ctx: Ctx): Promise<void> {
try { gaps = await firstProductReport(c, appId, { iaps, versionState: reviewed ? vstate(reviewed) : undefined }); } catch (e) { log('apple', `first in-app purchases not compared with the review: ${(e as Error).message.slice(0, 200)}`); }
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}`;
return v ? `app version ${versionLabel(v)}` : `${kind} ${id}`;
};
if (ctx.args.has('json')) {
// phased release of the newest versions that are live or close to it (rollout share on the desktop timeline)
@ -115,16 +121,16 @@ export async function run(ctx: Ctx): Promise<void> {
}
console.log(`${app.attributes.name} · ${app.attributes.bundleId} · Apple ID ${appId}`);
for (const v of versions) console.log(` version ${v.attributes.versionString.padEnd(8)} ${v.attributes.appVersionState ?? v.attributes.appStoreState}${v.relationships?.build?.data ? ' (build attached)' : ''}`);
for (const v of versions) console.log(` version ${v.attributes.versionString.padEnd(8)} ${platformLabel(platformOf(v)).padEnd(8)} ${v.attributes.appVersionState ?? v.attributes.appStoreState}${v.relationships?.build?.data ? ' (build attached)' : ''}`);
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}${empty.has(s.id) ? `: ${EMPTY_DRAFT}` : ''} ${s.attributes.submittedDate?.slice(0, 10) ?? ''}`.trimEnd());
for (const s of subs) console.log(` review submission ${s.id} ${platformLabel(s.attributes.platform)} ${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:`);
console.log(` review submission ${u.id} (${platformLabel(u.platform)}) 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}${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 <n>${newest ? ` (newest uploaded build: ${newest})` : ''}`);
console.log(` resubmit with: storebound apple resubmit --build <n>${u.platform === 'IOS' ? '' : ` --platform ${platformLabel(u.platform).toLowerCase()}`}${newest ? ` (newest uploaded build: ${newest})` : ''}`);
}
for (const g of gaps) warn('apple', `${g.msg}; ${g.fix}`);
if ('error' in caps) log('apple', `capabilities not compared: ${caps.error}`);

View file

@ -9,26 +9,26 @@ import type { Asc, J } from './asc';
import { one } from './jsonapi';
import * as rt from '../lib/rt';
/** The draft review submission of the app (READY_FOR_REVIEW, iOS), or null. */
export async function findDraft(c: Asc, appId: string): Promise<string | null> {
return (await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&filter[platform]=IOS&filter[state]=READY_FOR_REVIEW&limit=10`))[0]?.id ?? null;
/** The draft review submission of the app (READY_FOR_REVIEW) on one platform (iOS unless named), or null. */
export async function findDraft(c: Asc, appId: string, platform = 'IOS'): Promise<string | null> {
return (await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&filter[platform]=${platform}&filter[state]=READY_FOR_REVIEW&limit=10`))[0]?.id ?? null;
}
/** The draft's id: the existing one, else a new draft created once. */
export async function draftSubmission(c: Asc, appId: string, root: string, tag: string): Promise<string> {
return (await ensureDraft(c, appId, root, tag)).id;
export async function draftSubmission(c: Asc, appId: string, root: string, tag: string, platform = 'IOS'): Promise<string> {
return (await ensureDraft(c, appId, root, tag, platform)).id;
}
/** The journal key of the draft storebound created (cli/lib/upsert.ts). */
export const draftJournalKey = (appId: string): string => `apple:${appId}:reviewSubmission:draft`;
export const draftJournalKey = (appId: string, platform = 'IOS'): string => `apple:${appId}:reviewSubmission:draft${platform === 'IOS' ? '' : `:${platform}`}`;
/** 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 }> {
export async function ensureDraft(c: Asc, appId: string, root: string, tag: string, platform = 'IOS'): Promise<{ id: string; created: boolean }> {
const r = await findOrCreate<string>({ what: 'draft review submission', tag, unique: true,
find: () => findDraft(c, appId),
journal: { root, key: draftJournalKey(appId), idOf: id => id,
find: () => findDraft(c, appId, platform),
journal: { root, key: draftJournalKey(appId, platform), idOf: id => id,
byId: async id => { const d = (await c.maybe(`/v1/reviewSubmissions/${id}`))?.data; return d?.attributes?.state === 'READY_FOR_REVIEW' ? id : null; } },
create: async () => (await c.asc('POST', '/v1/reviewSubmissions', { data: { type: 'reviewSubmissions', attributes: { platform: 'IOS' }, relationships: { app: one('apps', appId) } } })).data.id as string });
create: async () => (await c.asc('POST', '/v1/reviewSubmissions', { data: { type: 'reviewSubmissions', attributes: { platform }, relationships: { app: one('apps', appId) } } })).data.id as string });
if (r.created) log(tag, `draft review submission ${r.value} created`);
return { id: r.value, created: r.created };
}
@ -81,10 +81,10 @@ export async function readItems(c: Asc, sid: string): Promise<SubItem[]> {
});
}
/** Every open review submission of the app on iOS (OPEN_STATES), each with its items. `also`: a submission id a 409
* named, read directly (a list may lag behind, or its state may be outside the filter). */
export async function openSubmissions(c: Asc, appId: string, also?: string): Promise<OpenSubmission[]> {
const subs = await c.all(`/v1/apps/${appId}/reviewSubmissions?filter[platform]=IOS&filter[state]=${OPEN_STATES.join(',')}&include=items&limit=50`);
/** Every open review submission of the app on one platform (iOS unless named; OPEN_STATES), each with its items.
* `also`: a submission id a 409 named, read directly (a list may lag behind, or its state may be outside the filter). */
export async function openSubmissions(c: Asc, appId: string, also?: string, platform = 'IOS'): Promise<OpenSubmission[]> {
const subs = await c.all(`/v1/apps/${appId}/reviewSubmissions?filter[platform]=${platform}&filter[state]=${OPEN_STATES.join(',')}&include=items&limit=50`);
if (also && !subs.some((x: J) => x.id === also)) { const d = (await c.maybe(`/v1/reviewSubmissions/${also}`))?.data; if (d) subs.push(d); }
const out: OpenSubmission[] = [];
for (const x of subs) out.push({ id: x.id, state: x.attributes?.state ?? '?', submittedDate: x.attributes?.submittedDate ?? undefined, items: await readItems(c, x.id) });