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) });

View file

@ -67,22 +67,22 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
'apple subscriptions': [() => import('./apple/subscriptions'), 'subscription groups, subscriptions, localizations, prices (equalized + overrides + --csv), intro offers, availability, review screenshot (asks first)'],
'apple iap-review': [() => import('./apple/iap-review'), 'IAP review screenshots + product states'],
'apple iap-submit': [() => import('./apple/iap-submit'), 'IAP + subscription versions into a review submission (first of a type: --with-version), then submit (asks first; --no-submit keeps a draft); reuses the open submission, never a second one'],
'apple submit': [() => import('./apple/submit'), 'a version to App Review: --build <n> attached (by number, VALID), the open submission reused, the ready IAPs / subscriptions with it, submitted with retry (dry run unless --yes)'],
'apple resubmit': [() => import('./apple/resubmit'), 'after a rejection: --build <n> attached to the rejected version, the UNRESOLVED_ISSUES submission reused (missing items added), resubmitted with retry (dry run unless --yes)'],
'apple submit': [() => import('./apple/submit'), 'a version to App Review: --build <n> attached (by number, VALID), the open submission reused, the ready IAPs / subscriptions with it, submitted with retry (dry run unless --yes); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
'apple resubmit': [() => import('./apple/resubmit'), 'after a rejection: --build <n> attached to the rejected version, the UNRESOLVED_ISSUES submission reused (missing items added), resubmitted with retry (dry run unless --yes); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
'apple offer-codes': [() => import('./apple/offer-codes'), 'offer codes for one-time purchases (campaigns; custom and one-time-use codes with --codes, asks first)'],
'apple win-back': [() => import('./apple/win-back'), 'win-back offers for lapsed subscribers (asks first: they go live on their start date)'],
'apple promoted': [() => import('./apple/promoted'), 'promoted in-app purchases on the product page: create, order, --enable (asks first)'],
'apple preorder': [() => import('./apple/preorder'), 'status | enable --release-date | end: App Store pre-orders per territory (asks first)'],
'apple cpp': [() => import('./apple/cpp'), 'custom product pages: list | create | upload --name | submit --name | visible --name (submit / visible ask first)'],
'apple events': [() => import('./apple/events'), 'in-app events: list | create | upload --name | submit --name (submit asks first)'],
'apple listing': [() => import('./apple/listing'), 'version, build, name / subtitle / description / keywords per locale; pull [--write]: live texts back into the listing file (diff first)'],
'apple screenshots': [() => import('./apple/screenshots'), '6.9" + 6.5" (+ 13" iPad) screenshots per locale; --variant <id>'],
'apple listing': [() => import('./apple/listing'), 'version, build, name / subtitle / description / keywords per locale; pull [--write]: live texts back into the listing file (diff first); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
'apple screenshots': [() => import('./apple/screenshots'), '6.9" + 6.5" (+ 13" iPad) screenshots per locale; --variant <id>; --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
'apple appinfo': [() => import('./apple/appinfo'), 'categories, age rating, content rights, Free price, territories'],
'apple review-details': [() => import('./apple/review-details'), 'App Review contact, demo account (from .storebound/, masked) + notes'],
'apple status': [() => import('./apple/status'), 'versions, builds, IAPs, review submissions (--wait-review, --cancel-submission <id>)'],
'apple status': [() => import('./apple/status'), 'versions, builds, IAPs, review submissions (--wait-review, --cancel-submission <id>); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
'apple creative-assets': [() => import('./apple/creative-assets'), 'Header and Search Results: product page header (21:9) and search results asset (3:2) per locale, or one universal 5244x2950 PNG; uploads to the Asset Library and selects it once processed (MD5 upsert); --add-for-review, --verify (read-only), --dry'],
'apple previews': [() => import('./apple/previews'), 'app preview videos per locale (MD5-idempotent, poster frame time code); --dry'],
'apple release': [() => import('./apple/release'), 'phased release on|pause|resume|complete, --manual-release (asks first)'],
'apple release': [() => import('./apple/release'), 'phased release on|pause|resume|complete, --manual-release (asks first); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
'apple signing': [() => import('./apple/signing'), 'status | ensure: bundle id capabilities, distribution certificate (key kept in .storebound/signing), App Store profiles installed for Xcode; --dry, asks first'],
'apple build': [() => import('./apple/build'), 'xcodebuild archive + exportArchive (ExportOptions.plist generated, next build number, API key signing); Capacitor, React Native, Flutter, Unity; --dry'],
'apple upload': [() => import('./apple/upload'), 'upload an IPA (App Store Connect API build upload, or --via altool), build number checked, waits for processing; --dry, asks first'],

View file

@ -82,6 +82,7 @@
"name",
"not-sent-for-review",
"off",
"platform",
"prune",
"src",
"variant",
@ -109,6 +110,7 @@
"apple listing": [
"build",
"include-drafts",
"platform",
"version",
"write"
],
@ -143,6 +145,7 @@
"jobs",
"name",
"not-sent-for-review",
"platform",
"prune",
"src",
"traffic",
@ -168,12 +171,14 @@
"apple release": [
"manual-release",
"phased",
"platform",
"version"
],
"apple resubmit": [
"build",
"new-version",
"no-submit",
"platform",
"resolve-iaps",
"version",
"with-version"
@ -185,6 +190,7 @@
"include-drafts",
"jobs",
"not-sent-for-review",
"platform",
"prune",
"src",
"variant",
@ -198,6 +204,7 @@
"cancel-submission",
"keychain",
"no-install",
"platform",
"timeout",
"version",
"wait-review"
@ -206,6 +213,7 @@
"build",
"new-version",
"no-submit",
"platform",
"resolve-iaps",
"version",
"with-version"
@ -860,6 +868,7 @@
"no-scan",
"not-sent-for-review",
"out",
"platform",
"play",
"prune",
"react-native",
@ -885,6 +894,7 @@
"hook",
"lang",
"languages",
"platform",
"release",
"since",
"translate"
@ -1206,7 +1216,7 @@
"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]",
"storebound apple listing --version 1.0.0 [--platform ios|macos|tvos|visionos] [--build 3] [--include-drafts] [--dry] [--json]",
"storebound apple listing pull [--version x.y.z] [--write] [--json]"
],
"apple next-build": [
@ -1237,7 +1247,7 @@
"storebound apple release --manual-release [--version 1.2.0] [--yes]"
],
"apple resubmit": [
"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]"
],
"apple review-details": [
"storebound apple review-details [--contact-from <Apple ID of another app>] [--dry]"
@ -1250,7 +1260,7 @@
"storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--json]"
],
"apple status": [
"storebound apple status [--json]",
"storebound apple status [--platform ios|macos|tvos|visionos] [--json]",
"storebound apple status --cancel-submission <id> [--yes]",
"storebound apple status --wait-review [--version 1.2.0] [--timeout 48h]"
],
@ -1490,7 +1500,7 @@
"storebound whats-new [--since v1.1.0] [--release 1.2.0] [--from-git] [--lang en-US] [--all] [--hook \"text\"] [--dry]",
"storebound whats-new --languages en-US,de-DE,tr [--dir store/whats-new] [--translate \"<command>\"] [--force] [--dry] [--json]",
"storebound whats-new check [--dir store/whats-new] [--json] limits per store (Play 500, App Store 4000), untranslated files",
"storebound whats-new apply [--dir store/whats-new] [--dry] [--json] checked files -> both listing files"
"storebound whats-new apply [--dir store/whats-new] [--platform ios|macos|tvos|visionos] [--dry] [--json] checked files -> both listing files"
]
}
}

View file

@ -3,7 +3,7 @@
// workspace (`storebound ws status`, `storebound release status`), so all of them read an app the same way.
// Every read is a storebound child command; `exec` runs it (serve tracks it for cancellation, the CLI just awaits it).
import type { AppStatus, ProjectSummary } from './desktop-protocol';
import { appIdentity, appleFirstProductWarnings, appleProductIds, appleReleases, catalogParity, playProductIds, playReleases, releaseTrains } from './portfolio';
import { appIdentity, appleFirstProductWarnings, applePlatformWarnings, appleProductIds, appleReleases, catalogParity, playProductIds, playReleases, releaseTrains } from './portfolio';
import type { RunResult } from './runner';
export type Exec = (argv: string[], cwd: string) => Promise<RunResult>;
@ -46,7 +46,7 @@ export async function fillAppStatus(e: AppStatus, dir: string, o: { live: boolea
const ios = apple?.data ? appleReleases(apple.data) : [], android = play?.data ? playReleases(play.data) : [];
e.release = { source: 'live', ...releaseTrains(ios, android), all: [...ios, ...android] };
// first in-app purchases left out of the review: a warning on the release status and in the desktop's checks
const firstWarn = apple?.data ? appleFirstProductWarnings(apple.data) : [];
const firstWarn = apple?.data ? [...appleFirstProductWarnings(apple.data), ...applePlatformWarnings(apple.data)] : [];
if (firstWarn.length) e.warnings = firstWarn;
if (e.check) for (const w of firstWarn) if (!e.check.warnings.includes(w)) e.check.warnings.push(w);
e.catalog = catalogParity(summary, apple?.data ? appleProductIds(apple.data) : undefined, play?.data ? playProductIds(play.data) : undefined);

View file

@ -166,6 +166,8 @@ export type ReleasePhase = 'live' | 'rollout' | 'halted' | 'released' | 'approve
export interface StoreRelease {
store: 'ios' | 'android';
/** App Store platform of the version (IOS, MAC_OS, ...); absent on Play. */
platform?: string;
version: string | null;
/** iOS build number (CFBundleVersion) / highest Android versionCode. */
build: string | null;

View file

@ -58,6 +58,8 @@ export function compareVersions(a: string, b: string): number {
return 0;
}
const PLATFORM_LABEL: Record<string, string> = { IOS: 'iOS', MAC_OS: 'macOS', TV_OS: 'tvOS', VISION_OS: 'visionOS' };
/** Version rows of GET /v1/apps/{id}/appStoreVersions. */
export const versionInfos = (rows: { id: string; attributes: Record<string, unknown> }[]): VersionInfo[] =>
rows.map(r => ({ id: r.id, versionString: String(r.attributes.versionString ?? ''), state: String(r.attributes.appVersionState ?? r.attributes.appStoreState ?? ''), platform: String(r.attributes.platform ?? 'IOS') }));
@ -67,7 +69,7 @@ export const versionInfos = (rows: { id: string; attributes: Record<string, unkn
* string changes only while PREPARE_FOR_SUBMISSION), create a new one, or refuse (the number exists, is not higher than
* a version that went through review, or another version is in review / pending release). Pure.
*/
export function planVersion(rows: { id: string; attributes: Record<string, unknown> }[], want: string, attrs: { copyright: string; releaseType: string }): VersionPlan {
export function planVersion(rows: { id: string; attributes: Record<string, unknown> }[], want: string, attrs: { copyright: string; releaseType: string }, platform = 'IOS'): VersionPlan {
const existing = versionInfos(rows);
const editable = existing.find(v => EDITABLE_STATES.has(v.state));
const others = existing.filter(v => v !== editable);
@ -96,8 +98,8 @@ export function planVersion(rows: { id: string; attributes: Record<string, unkno
}
const busy = others.find(v => IN_PROGRESS.has(v.state));
if (busy) return refuse(`version ${busy.versionString} is ${busy.state}; App Store Connect allows one version in progress (wait for its release or remove it from review)`);
const attributes = { platform: 'IOS', versionString: want, copyright: attrs.copyright, releaseType: attrs.releaseType };
return { ...base, action: 'create', reason: `create version ${want} (no editable iOS version)`, changes: Object.entries(attributes).map(([field, to]) => ({ field, from: null, to })), attributes };
const attributes = { platform, versionString: want, copyright: attrs.copyright, releaseType: attrs.releaseType };
return { ...base, action: 'create', reason: `create version ${want} (no editable ${PLATFORM_LABEL[platform] ?? platform} version)`, changes: Object.entries(attributes).map(([field, to]) => ({ field, from: null, to })), attributes };
}
// ---- translation drafts ----

View file

@ -63,6 +63,18 @@ export function phasedFraction(p: { state?: string; currentDayNumber?: number |
return d ? PHASED_SCHEDULE[d - 1]! : p.state === 'INACTIVE' ? 0 : null;
}
const OTHER_PLATFORM: Record<string, string> = { MAC_OS: 'macOS', TV_OS: 'tvOS', VISION_OS: 'visionOS' };
/** Versions of the app's other App Store platforms (macOS, tvOS, visionOS) that are not live or replaced: the train
* is iOS, so these are named as warnings ("macOS 0.8.7: REJECTED") instead of being invisible. Pure. */
export function applePlatformWarnings(data: unknown): string[] {
const versions = ((data as J)?.versions ?? []) as J[];
return versions.filter(v => typeof v?.attributes?.versionString === 'string' && OTHER_PLATFORM[v.attributes.platform ?? 'IOS'])
.map(v => ({ v, state: String(v.attributes.appVersionState ?? v.attributes.appStoreState ?? 'UNKNOWN') }))
.filter(({ state }) => !/READY_FOR_SALE|READY_FOR_DISTRIBUTION|REPLACED_WITH_NEW_VERSION|REMOVED_FROM_SALE/.test(state))
.map(({ v, state }) => `App Store ${OTHER_PLATFORM[v.attributes.platform]} ${v.attributes.versionString}: ${state} (storebound apple status --platform ${OTHER_PLATFORM[v.attributes.platform]!.toLowerCase()})`);
}
export function appleReleases(data: unknown): StoreRelease[] {
const versions = ((data as J)?.versions ?? []) as J[];
const recent = (((data as J)?.builds ?? []) as J[]);
@ -79,7 +91,7 @@ export function appleReleases(data: unknown): StoreRelease[] {
// live but still rolling out (phased release on, not complete): the same as a Play staged rollout
if (phase === 'live' && prState === 'ACTIVE') phase = 'rollout';
if (phase === 'live' && prState === 'PAUSED') phase = 'halted';
const r: StoreRelease = { store: 'ios', version: v.attributes.versionString as string, build: (buildId && builds.get(buildId)) ? String(builds.get(buildId)) : null, state, phase, fraction, track: null };
const r: StoreRelease = { store: 'ios', platform: 'IOS', version: v.attributes.versionString as string, build: (buildId && builds.get(buildId)) ? String(builds.get(buildId)) : null, state, phase, fraction, track: null };
if (prState) r.phased = { state: prState, day };
if (typeof v.attributes.createdDate === 'string') r.date = v.attributes.createdDate;
return r;

View file

@ -149,7 +149,7 @@ export function latestVersion(vs: PVersion[]): PVersion | undefined {
* Plan for submitting `want` (product ids of in-app purchases and subscriptions). withVersion: put the editable app
* version into the same submission (needed for the first item of a type, or while the app has no approved version).
*/
export function planIapSubmission(s: SubmitState, want: string[], o: { withVersion?: boolean; appVersionId?: string } = {}): SubmitPlan {
export function planIapSubmission(s: SubmitState, want: string[], o: { withVersion?: boolean; appVersionId?: string; platform?: string } = {}): SubmitPlan {
const plan: SubmitPlan = { items: [], skipped: [], firstOfType: [], hasApprovedVersion: s.appVersions.some(v => APPROVED_APP.has(v.state)), needsAppVersion: false, blockers: [], prepare: [],
submit: { method: 'PATCH', path: '/v1/reviewSubmissions/${submission}', note: 'submit for App Review', body: {} } };
const approvedTypes = new Set([...s.iaps.filter(p => APPROVED_PRODUCT.has(p.state)).map(p => p.type), ...(s.subs.some(x => APPROVED_PRODUCT.has(x.state)) ? ['AUTO_RENEWABLE'] : [])]);
@ -212,7 +212,7 @@ export function planIapSubmission(s: SubmitState, want: string[], o: { withVersi
}
if (plan.items.length + (s.draft?.itemVersionIds.length ?? 0) > MAX_SUBMISSION_ITEMS) plan.blockers.push(`more than ${MAX_SUBMISSION_ITEMS} items in one submission: use --only for a part`);
const sid = s.draft?.id ?? '${submission}';
if (!s.draft && plan.items.length) plan.prepare.push({ method: 'POST', path: '/v1/reviewSubmissions', note: 'new draft review submission', body: { data: { type: 'reviewSubmissions', attributes: { platform: 'IOS' }, relationships: { app: one('apps', '${app}') } } } });
if (!s.draft && plan.items.length) plan.prepare.push({ method: 'POST', path: '/v1/reviewSubmissions', note: 'new draft review submission', body: { data: { type: 'reviewSubmissions', attributes: { platform: o.platform ?? 'IOS' }, relationships: { app: one('apps', '${app}') } } } });
for (const it of plan.items) plan.prepare.push({ method: 'POST', path: '/v1/reviewSubmissionItems', note: it.label,
body: { data: { type: 'reviewSubmissionItems', relationships: { reviewSubmission: one('reviewSubmissions', sid), [it.kind]: one(ITEM_TYPE[it.kind], it.versionId) } } } });
plan.submit = { method: 'PATCH', path: `/v1/reviewSubmissions/${sid}`, note: 'submit for App Review', body: { data: { type: 'reviewSubmissions', id: sid, attributes: { submitted: true } } } };

View file

@ -56,7 +56,9 @@ export function rendersFindings(store: 'apple' | 'play', r: RendersCheck | undef
}
export interface AppleSnapshot {
version: { versionString: string; state: string; copyright?: string | null } | null;
version: { versionString: string; state: string; copyright?: string | null; platform?: string } | null;
/** Editable or rejected versions of the app's other platforms (an app's macOS version has its own state). */
otherPlatformVersions?: { versionString: string; state: string; platform: string }[];
/** app.copyright from the config (undefined: not set). */
configCopyright?: string;
build: { number: string; processingState: string; usesNonExemptEncryption: boolean | null; declarationState: string | null } | null;
@ -192,15 +194,22 @@ export function copyrightRules(s: Pick<AppleSnapshot, 'version' | 'configCopyrig
...(cfg && EDITABLE.includes(s.version.state) ? { action: fixAppleListing(s.version.versionString) } : {}) }];
}
const PLATFORM_NAMES: Record<string, string> = { IOS: 'iOS', MAC_OS: 'macOS', TV_OS: 'tvOS', VISION_OS: 'visionOS' };
export function appleRules(s: AppleSnapshot): Finding[] {
const f: Finding[] = [];
const add = (level: Level, code: string, msg: string, fix?: string, action?: FixAction, url?: string) => f.push({ store: 'apple', level, code, msg, fix, ...(action ? { action } : {}), ...(url ? { url } : {}) });
const listing = s.version && EDITABLE.includes(s.version.state) ? fixAppleListing(s.version.versionString) : undefined;
const vl = s.version ? `${s.version.versionString} (${PLATFORM_NAMES[s.version.platform ?? 'IOS'] ?? s.version.platform})` : '';
if (!s.version) add('blocker', 'version', 'no App Store version being prepared', 'storebound apple listing --version x.y.z');
else if (EDITABLE.includes(s.version.state)) add('ok', 'version', `version ${s.version.versionString}: ${s.version.state}`);
else if (IN_FLIGHT.includes(s.version.state)) add('warn', 'version', `version ${s.version.versionString} is ${s.version.state} (nothing to prepare)`);
else add('warn', 'version', `version ${s.version.versionString}: ${s.version.state}`);
else if (EDITABLE.includes(s.version.state)) add('ok', 'version', `version ${vl}: ${s.version.state}`);
else if (IN_FLIGHT.includes(s.version.state)) add('warn', 'version', `version ${vl} is ${s.version.state} (nothing to prepare)`);
else add('warn', 'version', `version ${vl}: ${s.version.state}`);
for (const o of s.otherPlatformVersions ?? []) {
const name = PLATFORM_NAMES[o.platform] ?? o.platform;
add('warn', 'version-platform', `version ${o.versionString} (${name}) is ${o.state} too: this report covers ${vl || 'iOS'} only`, `storebound validate --platform ${name.toLowerCase()}; storebound apple status lists every platform`);
}
f.push(...copyrightRules(s));
if (!s.build) add('blocker', 'build', 'no build attached to the version', 'storebound apple listing --version x.y.z --build N (pick the build number you just uploaded)');

View file

@ -27,6 +27,7 @@ export interface ToolDef {
const only: JsonSchema = { type: 'string', description: 'Comma-separated ids / language codes to limit the run to (optional)' };
const lang: JsonSchema = { type: 'string', description: 'Comma-separated language ids from the config (default: all)' };
const variant: JsonSchema = { type: 'string', pattern: '^[a-z0-9][a-z0-9-]*$', description: 'A/B variant id from shots.variants (default: the base renders)' };
const platform: JsonSchema = { type: 'string', enum: ['ios', 'macos', 'tvos', 'visionos'], description: 'App Store platform of the version (default: the one that has the version this needs; when several do, the command asks for it)' };
const source: JsonSchema = { type: 'string', enum: ['web', 'ios-sim', 'android-emu', 'folder'], description: 'Default: from the config' };
const APP_NAMES = '^[^,\\x00-\\x1f]{1,120}(,[^,\\x00-\\x1f]{1,120}){0,99}$';
const opt = (flag: string, v: unknown): string[] => (v === undefined || v === null || v === '' ? [] : [`--${flag}`, String(v)]);
@ -55,8 +56,8 @@ export const TOOLS: ToolDef[] = [
args: i => [...opt('store', i.store), ...flag('originality', i.originality), ...(i.originality ? opt('against', i.against) : [])] },
{ name: 'validate', title: 'Store readiness report', command: ['validate'], write: false, network: true,
description: 'Read-only readiness report for App Store Connect and/or Google Play with ordered blockers (build, encryption, metadata, screenshots, review details and demo account, age rating, prices, IAPs, subscriptions, privacy, delete-account and terms URLs, app-ads.txt, tracks, products, base plans).',
props: { store: { type: 'string', enum: ['both', 'apple', 'play'], default: 'both' } },
args: i => (i.store === 'apple' ? ['--apple'] : i.store === 'play' ? ['--play'] : []) },
props: { store: { type: 'string', enum: ['both', 'apple', 'play'], default: 'both' }, platform },
args: i => [...(i.store === 'apple' ? ['--apple'] : i.store === 'play' ? ['--play'] : []), ...opt('platform', i.platform)] },
{ name: 'report', title: 'Shareable readiness report', command: ['report'], write: false, network: true,
description: 'One self-contained HTML page with the scan, check and validate results (read-only; validate only for the stores whose credentials are there, else the page says why): summary per store, findings by store and severity with guideline, evidence, fix and console link, the compliance questions only the owner can answer. Writes only .storebound/reports/readiness-<date>.html (masked: no credentials, key ids, emails or demo account) and returns its path and the summary; never opens a browser.',
props: { format: { type: 'string', enum: ['html', 'md'], default: 'html' }, offline: { type: 'boolean', default: false, description: 'Skip validate (no store reads)' } },
@ -141,7 +142,8 @@ export const TOOLS: ToolDef[] = [
{ name: 'analytics_setup', title: 'Start App Store analytics reports', command: ['analytics', 'setup'], write: true, network: true, idempotent: true,
description: 'Create the ONGOING App Store Connect analytics report request once (needs an Admin key; reports start 1-2 days later). Changes nothing public.' },
{ name: 'apple_status', title: 'App Store status', command: ['apple', 'status'], write: false, network: true,
description: 'Read-only App Store Connect overview: versions and their states, recent builds, in-app purchases, review submissions.' },
description: 'Read-only App Store Connect overview: versions of every platform (iOS, macOS, ...) and their states, recent builds, in-app purchases, review submissions with their platform.',
props: { platform }, args: i => opt('platform', i.platform) },
{ name: 'apple_next_build', title: 'Next iOS build number', command: ['apple', 'next-build'], write: false, network: true,
description: 'Next safe CFBundleVersion: one above every build uploaded for the app.' },
{ name: 'apple_signing_status', title: 'Code signing status', command: ['apple', 'signing', 'status'], write: false, network: true,
@ -203,11 +205,11 @@ export const TOOLS: ToolDef[] = [
description: 'Upload (replace when changed) the App Review screenshot of every in-app purchase. Never submits.' },
{ name: 'apple_listing', title: 'Push App Store version + text', command: ['apple', 'listing'], write: true, network: true, idempotent: true,
description: 'Create / update the editable version (manual release), attach a build by explicit number, and push name / subtitle / description / keywords / promo / what\'s new per locale. dry_run prints the plan: version keep / update / create / refuse with the reason, per locale old -> new per field; draft locales are skipped (owner terminal run with --include-drafts).',
props: { version: { type: 'string', pattern: '^\\d+(\\.\\d+){0,2}$' }, build: { type: 'string', description: 'Exact build number to attach (use the number you just uploaded, never "latest")' }, only },
required: ['version'], args: i => [...opt('version', i.version), ...opt('build', i.build), ...opt('only', i.only)] },
props: { version: { type: 'string', pattern: '^\\d+(\\.\\d+){0,2}$' }, build: { type: 'string', description: 'Exact build number to attach (use the number you just uploaded, never "latest")' }, only, platform },
required: ['version'], args: i => [...opt('version', i.version), ...opt('platform', i.platform), ...opt('build', i.build), ...opt('only', i.only)] },
{ name: 'apple_screenshots', title: 'Upload App Store screenshots', command: ['apple', 'screenshots'], write: true, destructive: true, network: true, idempotent: true,
description: 'Sync 6.9" and 6.5" (+ 13" iPad with apple.ipad) screenshots per locale from the renders folder (or an A/B variant folder; MD5-idempotent; changed sets are replaced). Slow: prefer the terminal with a log.',
props: { only, variant }, args: i => [...opt('only', i.only), ...opt('variant', i.variant)] },
props: { only, variant, platform }, args: i => [...opt('only', i.only), ...opt('variant', i.variant), ...opt('platform', i.platform)] },
{ name: 'apple_appinfo', title: 'App Store app info', command: ['apple', 'appinfo'], write: true, network: true, idempotent: true,
description: 'Categories, age rating declaration, content rights, Free price and availability (when missing).' },
{ name: 'apple_review_details', title: 'App Review information', command: ['apple', 'review-details'], write: true, network: true, idempotent: true,
@ -217,8 +219,8 @@ export const TOOLS: ToolDef[] = [
props: { build: { type: 'string', description: 'Build number (default: every build without an answer)' } }, args: i => opt('build', i.build) },
{ name: 'apple_phased_release', title: 'Phased release on / pause', command: ['apple', 'release'], write: true, network: true, idempotent: true,
description: 'Turn on the 7-day phased release for the upcoming version, or pause an active one (pausing is not a rollback). Resume / complete / release are owner-only terminal commands.',
props: { action: { type: 'string', enum: ['on', 'pause'] }, version: { type: 'string' } }, required: ['action'],
args: i => ['--phased', String(i.action), ...opt('version', i.version)] },
props: { action: { type: 'string', enum: ['on', 'pause'] }, version: { type: 'string' }, platform }, required: ['action'],
args: i => ['--phased', String(i.action), ...opt('version', i.version), ...opt('platform', i.platform)] },
{ name: 'shots_capture', title: 'Capture raw frames', command: ['shots', 'capture'], write: true, network: false, idempotent: true,
description: 'Raw app or game frames per language into shots.frames from a web build (Playwright), the iOS Simulator (simctl), an Android emulator / device (adb) or a folder check. Local files only; a simulator it boots is shut down again, an emulator is never booted. dry_run lists the plan and the simctl / adb commands.',
props: { lang, only: { type: 'string', description: 'Comma-separated shot names' }, device: { type: 'string', enum: ['phone', 'tablet'] }, source,
@ -342,8 +344,8 @@ export const TOOLS: ToolDef[] = [
args: i => [...opt('only', i.only), ...flag('with-version', i.with_version), '--json', '--dry'] },
{ name: 'apple_listing_pull', title: 'Pull App Store texts', command: ['apple', 'listing', 'pull'], write: true, network: true, idempotent: true,
description: 'Read the live App Store texts (name, subtitle, description, keywords, promotional text, what\'s new per locale) and diff them against the App Store listing file. dry_run shows the diff; with dry_run false the file is updated (local file only).',
props: { version: { type: 'string', pattern: '^\\d+(\\.\\d+){0,2}$', description: 'Version to read (default: the editable one, else the newest)' } },
args: i => [...opt('version', i.version), '--json', ...(i.dry_run === false ? ['--write'] : [])] },
props: { version: { type: 'string', pattern: '^\\d+(\\.\\d+){0,2}$', description: 'Version to read (default: the editable one, else the newest)' }, platform },
args: i => [...opt('version', i.version), ...opt('platform', i.platform), '--json', ...(i.dry_run === false ? ['--write'] : [])] },
{ name: 'play_listing_pull', title: 'Pull Google Play texts', command: ['play', 'listing', 'pull'], write: true, network: true, idempotent: true,
description: 'Read the live Play listings (title, short and full description per language; one throwaway edit, deleted) and diff them against the Play listing file. dry_run shows the diff; with dry_run false the file is updated (local file only).',
args: i => ['--json', ...(i.dry_run === false ? ['--write'] : [])] },
@ -365,7 +367,7 @@ export const TOOLS: ToolDef[] = [
args: () => ['--json'] },
{ name: 'whats_new_apply', title: 'Apply release notes per language', command: ['whats-new', 'apply'], write: true, network: false, idempotent: true,
description: 'Write the checked per-language release notes into both listing files (Play "What\'s new" per Play code, App Store whatsNew per locale). Refuses while a language is over a limit or untranslated. Local files only; nothing is sent to a store.',
args: () => ['--json'] },
props: { platform }, args: i => [...opt('platform', i.platform), '--json'] },
{ name: 'listing_translate', title: 'Listing translation drafts', command: ['listing', 'translate'], write: true, network: false, idempotent: true,
description: 'Add a section per target language to both listing files (App Store: name, subtitle, promotionalText, keywords, description; Google Play: title, short, full) with the source language\'s text and a status=translate marker. Then translate each field in place within the store limits (App Store subtitle 30, promotional text 170, keywords 100 comma-separated without duplicates or name words, description 4000; Play title 30, short 80, full 4000), set the marker to status=draft and run listing_check. The owner reviews the drafts (storebound listing reviewed). Reviewed sections are kept. Local files only.',
props: { to: { type: 'string', pattern: '^[A-Za-z0-9_-]+(,[A-Za-z0-9_-]+)*$', description: 'Comma-separated target languages (config ids, App Store locales or Play codes)' },

View file

@ -145,6 +145,7 @@ export const DESKTOP_ONLY: ToolDef[] = [
args: i => [...opt('store', i.store), ...(i.with_automatic === true ? ['--with-automatic'] : [])] },
];
const BUILD_NO = { type: 'string', pattern: '^\\d{1,9}(\\.\\d{1,9}){0,3}$', description: 'The build number (CFBundleVersion) the owner picked from the uploaded builds; never "the latest"' };
const PLATFORM = { type: 'string', enum: ['ios', 'macos', 'tvos', 'visionos'], description: 'App Store platform of the version (default: the one that has it; when several do, the command asks)' };
const VERSION = { type: 'string', pattern: '^\\d+(\\.\\d+){0,2}$', description: 'App Store version (default: the one an open submission holds, else the editable one)' };
const IDS = { type: 'string', pattern: '^[A-Za-z0-9._]{1,100}(,[A-Za-z0-9._]{1,100}){0,49}$', description: 'Comma-separated product ids' };
/** `--yes` (the owner's step of apple submit / resubmit) and `--allow-price-changes` only in the real run, after the dialog. */
@ -175,8 +176,8 @@ export const OWNER_TOOLS: ToolDef[] = [
...(i.percent !== undefined && (i.action === 'percent' || i.action === 'resume') ? ['--fraction', String(Number(i.percent) / 100)] : []), '--track', String(i.track ?? 'production'), ...(i.not_sent_for_review === true ? ['--not-sent-for-review'] : [])] },
{ name: 'owner_ios_phased', title: 'Change the App Store phased release', command: ['apple', 'release'], write: true, network: true,
description: 'The 7-day phased release of an App Store version: turn it on, pause it (not a rollback), resume it, or release to everyone now (complete).',
props: { action: { type: 'string', enum: ['on', 'pause', 'resume', 'complete'] }, version: { type: 'string', pattern: '^\\d+(\\.\\d+){0,2}$' } },
required: ['action'], args: i => ['--phased', String(i.action), ...(i.version ? ['--version', String(i.version)] : [])] },
props: { action: { type: 'string', enum: ['on', 'pause', 'resume', 'complete'] }, version: { type: 'string', pattern: '^\\d+(\\.\\d+){0,2}$' }, platform: PLATFORM },
required: ['action'], args: i => ['--phased', String(i.action), ...(i.version ? ['--version', String(i.version)] : []), ...(i.platform ? ['--platform', String(i.platform)] : [])] },
{ name: 'owner_reply_send', title: 'Send one approved reply', command: ['reviews', 'send'], write: true, network: true,
description: 'Sends one approved, unsent reply draft (.storebound/replies) to its store: public, the reviewer is notified. The draft checks (length, no incentives) apply.',
props: { file: { type: 'string', pattern: '^(apple|play)-[A-Za-z0-9._-]{1,120}\\.md$', description: 'Draft file name in .storebound/replies' } },
@ -201,12 +202,12 @@ export const OWNER_TOOLS: ToolDef[] = [
props: { only: IDS }, args: i => [...opt('only', i.only), ...real(i, '--allow-price-changes')] })),
{ name: 'owner_apple_submit', title: 'Submit for App Review', command: ['apple', 'submit'], write: true, network: true,
description: 'Sends an App Store version to App Review with the build the owner picked by its number: the build is attached, the open review submission reused (a new draft only when none is open), the in-app purchases and subscriptions of the config that are ready go with it, then it is submitted (retried while Apple says "not ready yet"). After approval it goes live as the version\'s release setting says.',
props: { build: BUILD_NO, version: VERSION, resolve_iaps: { type: 'boolean', default: false, description: 'Mark REJECTED in-app purchase / subscription items of a submission back from App Review as resolved' } },
required: ['build'], args: i => ['--build', String(i.build), ...opt('version', i.version), ...(i.resolve_iaps === true ? ['--resolve-iaps'] : []), ...real(i, '--yes')] },
props: { build: BUILD_NO, version: VERSION, platform: PLATFORM, resolve_iaps: { type: 'boolean', default: false, description: 'Mark REJECTED in-app purchase / subscription items of a submission back from App Review as resolved' } },
required: ['build'], args: i => ['--build', String(i.build), ...opt('version', i.version), ...opt('platform', i.platform), ...(i.resolve_iaps === true ? ['--resolve-iaps'] : []), ...real(i, '--yes')] },
{ name: 'owner_apple_resubmit', title: 'Resubmit to App Review', command: ['apple', 'resubmit'], write: true, network: true,
description: 'After a rejection: attaches the build the owner picked by its number to the rejected version, reuses the submission back from App Review (UNRESOLVED_ISSUES; missing items added), marks its REJECTED app version item resolved (in-app purchase items too with resolve_iaps) and resubmits it.',
props: { build: BUILD_NO, version: VERSION, resolve_iaps: { type: 'boolean', default: false, description: 'Also mark REJECTED in-app purchase / subscription items resolved (after fixing what App Review asked for)' } },
required: ['build'], args: i => ['--build', String(i.build), ...opt('version', i.version), ...(i.resolve_iaps === true ? ['--resolve-iaps'] : []), ...real(i, '--yes')] },
props: { build: BUILD_NO, version: VERSION, platform: PLATFORM, resolve_iaps: { type: 'boolean', default: false, description: 'Also mark REJECTED in-app purchase / subscription items resolved (after fixing what App Review asked for)' } },
required: ['build'], args: i => ['--build', String(i.build), ...opt('version', i.version), ...opt('platform', i.platform), ...(i.resolve_iaps === true ? ['--resolve-iaps'] : []), ...real(i, '--yes')] },
{ name: 'owner_apple_iap_submit', title: 'Submit in-app purchases for review', command: ['apple', 'iap-submit'], write: true, network: true,
description: 'Sends the in-app purchases and subscriptions of the config that are ready to App Review in the open review submission (reused, never a second draft). The first of a type needs an app version in the same submission (with_version: the editable one goes too, and submitting submits the app).',
props: { with_version: { type: 'boolean', default: false }, resolve_iaps: { type: 'boolean', default: false }, only: IDS },

View file

@ -14,7 +14,8 @@
// Live apps: crash / ANR vitals (Play Reporting API) and App Store diagnostics, as in `storebound health`: over a Play
// bad behavior threshold is a blocker, near it a warning; a store without vitals access is only noted. --no-health skips.
import { templateBlockers } from './lib/template-answers';
import { ascClient, type J } from './apple/asc';
import { ascClient, EDITABLE as APPLE_EDITABLE, type J } from './apple/asc';
import { allVersions, type AscPlatform, parsePlatform, platformOf, stateOf, versionsPath } from './apple/platform';
import { capabilityReport } from './apple/signing';
import { firstProductReport } from './apple/first-products';
import { requiredDisplayTypes } from './apple/screenshots';
@ -55,8 +56,14 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
const appId = await c.app();
log('validate', 'apple: reading version, build, localizations');
const versions = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=10`);
const v: J = (await c.editableVersion()) ?? versions[0] ?? null;
// the platform: --platform, else the one with an editable version (several: iOS, and the others are named)
const every = await allVersions(c, appId);
const editableOf = (x: J) => APPLE_EDITABLE.has(stateOf(x));
const withEditable = [...new Set(every.filter(editableOf).map(platformOf))];
const platform: AscPlatform = parsePlatform(ctx.args.get('platform')) ?? (withEditable.length === 1 ? withEditable[0]! : withEditable.includes('IOS') || !every.length ? 'IOS' : platformOf(every[0]));
const versions = await c.all(versionsPath(appId, platform, 10));
const v: J = (await c.editableVersion(platform)) ?? versions[0] ?? null;
const otherPlatformVersions = every.filter(x => platformOf(x) !== platform && editableOf(x)).map(x => ({ versionString: String(x.attributes.versionString), state: stateOf(x), platform: platformOf(x) }));
let build: AppleSnapshot['build'] = null;
const locales: AppleSnapshot['locales'] = [];
let reviewDetail: AppleSnapshot['reviewDetail'] = null;
@ -109,7 +116,8 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
subscriptions, expectedSubscriptions: wantSubs.map(({ group, sub }) => ({ id: sub.id, group: group.name,
locales: Object.keys(sub.listings).map(id => appleLocale.get(id)).filter((x): x is string => !!x) })),
profile: await collectProfile(ctx.loaded),
version: v ? { versionString: v.attributes.versionString, state: v.attributes.appVersionState ?? v.attributes.appStoreState, copyright: v.attributes.copyright ?? null } : null,
version: v ? { versionString: v.attributes.versionString, state: v.attributes.appVersionState ?? v.attributes.appStoreState, copyright: v.attributes.copyright ?? null, platform } : null,
otherPlatformVersions,
configCopyright: config.app.copyright,
build, locales, reviewDetail, ageRating,
expectedLocales: config.languages.map(l => l.apple).filter((x): x is string => !!x),

View file

@ -16,7 +16,8 @@
// STOREBOUND_TARGET_LANG (App Store locale, else Play code, else language id) in the environment; its stdout is
// the translation. Existing translated files are kept unless --force.
// storebound whats-new check [--dir store/whats-new] [--json] limits per store (Play 500, App Store 4000), untranslated files
// storebound whats-new apply [--dir store/whats-new] [--dry] [--json] checked files -> both listing files
// storebound whats-new apply [--dir store/whats-new] [--platform ios|macos|tvos|visionos] [--dry] [--json] checked files -> both listing files
// (--platform only goes into the printed apple listing command: the App Store's what's new is per platform version)
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import type { Language } from './lib/config';
@ -29,6 +30,7 @@ import {
resolveLanguages, setAppleWhatsNew, setPlayWhatsNew,
} from './lib/whats-new';
import * as rt from './lib/rt';
import { parsePlatform } from './apple/platform';
const TAG = 'whats-new';
const storeCode = (l: Language): string => l.apple ?? playCodes(l)[0] ?? l.id;
@ -93,6 +95,7 @@ export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const a = ctx.args;
if (a._[0] === 'check' || a._[0] === 'apply') {
parsePlatform(a.get('platform')); // a wrong --platform fails before anything is written
const dir = resolve(a.get('dir') ?? 'store/whats-new');
const files = readDir(dir, config.languages);
if (!files.length) throw new Error(`${dir}: no <language id>.md files`);
@ -114,7 +117,8 @@ export async function run(ctx: Ctx): Promise<void> {
if (a.has('dry')) { done(TAG, 'dry run: listing files not written'); return; }
if (playFile && md.play !== undefined) writeFileSync(playFile, md.play);
if (appleFile && md.apple !== undefined) writeFileSync(appleFile, md.apple);
done(TAG, `release notes of ${files.length} language(s) written into the listing files; push them with play release / apple listing`);
const pf = a.get('platform');
done(TAG, `release notes of ${files.length} language(s) written into the listing files; push them with play release / apple listing --version x.y.z${pf ? ` --platform ${pf}` : ''} (the App Store's what's new belongs to one platform's version: apple listing picks the platform with the editable version, or asks)`);
return;
}
const since = a.get('since') ?? (() => { try { return git(['describe', '--tags', '--abbrev=0'], root).trim(); } catch { return ''; } })();