diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ee5763..dd98a4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,16 @@ new placement goes first, the old one is removed only after Apple accepted it), links the live version page and does not retry the other live slots. Pitfall 76 rewritten. +- **macOS (tvOS, visionOS) versions are no longer invisible.** `apple status`, `listing`, `listing pull`, `resubmit`, + `submit`, `release` and `screenshots` take `--platform ios|macos|tvos|visionos` (MCP tools and the desktop's owner + actions: `platform`); without it they use the platform that has the version they need (a rejected one, an + UNRESOLVED_ISSUES submission, an editable one) and refuse, naming both, when several platforms do. Every version + and review submission lookup is per platform, so `resubmit` finds the macOS submission back from App Review and + `listing --version 0.8.7` works on the rejected macOS 0.8.7 while the iOS 0.8.7 is live. `apple status`, `validate`, + `release status`, the report and the desktop label every version with its platform; validate and release status warn + about another platform's editable or rejected version. `whats-new apply --platform` names it in the push command. + Pitfall 77. + ## 0.8.13 · 2026-10-18 - **iPhone Duo screenshots.** `apple.iphoneDuo: true` adds the foldable's outer (1398 x 2034) and inner (2007 x 2853) diff --git a/apps/desktop/src/lib/submit.ts b/apps/desktop/src/lib/submit.ts index ef55545..77bc7d5 100644 --- a/apps/desktop/src/lib/submit.ts +++ b/apps/desktop/src/lib/submit.ts @@ -18,7 +18,12 @@ export type SubmissionKind = 'waiting' | 'inReview' | 'unresolved' | 'draft' | ' export interface SubmissionItem { id: string; kind: string; label: string; state: string; rejected: boolean } export interface SubmissionView { id: string; state: string; kind: SubmissionKind; date: string | null; items: SubmissionItem[] } export interface BuildChoice { id: string; number: string; version: string | null; state: string; uploaded: string | null; valid: boolean; attachedTo: string | null } -export interface AppleVersion { id: string; version: string; state: string; build: string | null } +export interface AppleVersion { id: string; version: string; state: string; build: string | null; platform: string } +const PLATFORM_LABEL: Record = { IOS: 'iOS', MAC_OS: 'macOS', TV_OS: 'tvOS', VISION_OS: 'visionOS' }; +/** "iOS", "macOS", ... of an App Store platform (IOS when the data has none). */ +export const platformLabel = (p: string | undefined): string => PLATFORM_LABEL[p || 'IOS'] ?? p ?? 'iOS'; +/** The CLI's --platform value of an App Store platform. */ +export const platformFlag = (p: string | undefined): string => platformLabel(p).toLowerCase(); export interface AppleSubmitState { submissions: SubmissionView[]; /** The first submission back from App Review (UNRESOLVED_ISSUES), with its items. */ @@ -40,11 +45,11 @@ export function appleSubmitState(raw: unknown): AppleSubmitState { const d = obj(raw) as AppleStatusData & J; const versions: AppleVersion[] = arr(d.versions).map(v => { const o = obj(v), a = obj(o.attributes); - return { id: str(o.id), version: str(a.versionString), state: str(a.appVersionState) || str(a.appStoreState), build: str(obj(obj(obj(o.relationships).build).data).id) || null }; + return { id: str(o.id), version: str(a.versionString), state: str(a.appVersionState) || str(a.appStoreState), build: str(obj(obj(obj(o.relationships).build).data).id) || null, platform: str(a.platform) || 'IOS' }; }).filter(v => v.id && v.version); const label = (kind: string, versionId: string): string => { const v = versions.find(x => x.id === versionId); - return v && kind === 'appStoreVersion' ? `app version ${v.version}` : `${ITEM_NAME[kind] ?? kind} ${versionId}`; + return v && kind === 'appStoreVersion' ? `app version ${v.version} (${platformLabel(v.platform)})` : `${ITEM_NAME[kind] ?? kind} ${versionId}`; }; const empty = new Set(arr(d.emptyDrafts).map(str)); const unresolvedItems = new Map(arr(d.unresolved).map(u => [str(obj(u).id), arr(obj(u).items)] as const)); @@ -67,7 +72,7 @@ export function appleSubmitState(raw: unknown): AppleSubmitState { const unresolved = submissions.find(s => s.kind === 'unresolved') ?? null; const busy = submissions.find(s => s.kind === 'waiting' || s.kind === 'inReview') ?? null; const held = new Set(unresolved?.items.filter(i => i.kind === 'appStoreVersion').map(i => i.label) ?? []); - const editable = versions.find(v => held.has(`app version ${v.version}`)) ?? versions.find(v => SUBMITTABLE_APP.includes(v.state)) ?? null; + const editable = versions.find(v => held.has(`app version ${v.version} (${platformLabel(v.platform)})`)) ?? versions.find(v => SUBMITTABLE_APP.includes(v.state)) ?? null; return { submissions, unresolved, busy, builds, editable, versions }; } diff --git a/apps/desktop/src/screens/tabs/Submit.tsx b/apps/desktop/src/screens/tabs/Submit.tsx index 3486eb1..85094ed 100644 --- a/apps/desktop/src/screens/tabs/Submit.tsx +++ b/apps/desktop/src/screens/tabs/Submit.tsx @@ -10,7 +10,7 @@ import { useState } from 'react'; import { api, type Project } from '../../api'; import { type Key, t } from '../../i18n'; import { ago, date } from '../../lib/format'; -import { type AppleStatusData, appleSubmitState, type PlayTrackData, playSubmitState, type SubmissionKind } from '../../lib/submit'; +import { type AppleStatusData, appleSubmitState, platformFlag, platformLabel, type PlayTrackData, playSubmitState, type SubmissionKind } from '../../lib/submit'; import type { UnifiedApp } from '../../lib/identity'; import { useData, useRead } from '../../store'; import { ConsolePlanButton } from '../../ui/ConsolePlan'; @@ -35,9 +35,9 @@ export function AppleSubmitCard({ app, project }: { app: UnifiedApp; project: Pr const summary = d.statusOf(project)?.summary; const iaps = summary?.catalog.ios.length ?? 0; const picked = s?.builds.find(b => b.number === build && b.valid) ?? null; - const version = s?.unresolved ? s.versions.find(v => s.unresolved!.items.some(i => i.label === `app version ${v.version}`)) ?? s.editable : s?.editable ?? null; + const version = s?.unresolved ? s.versions.find(v => s.unresolved!.items.some(i => i.label === `app version ${v.version} (${platformLabel(v.platform)})`)) ?? s.editable : s?.editable ?? null; const rejectedIap = s?.unresolved?.items.some(i => i.rejected && i.kind !== 'appStoreVersion') ?? false; - const args = { build, ...(version ? { version: version.version } : {}), ...(resolveIaps ? { resolve_iaps: true } : {}) }; + const args = { build, ...(version ? { version: version.version, platform: platformFlag(version.platform) } : {}), ...(resolveIaps ? { resolve_iaps: true } : {}) }; const refresh = () => { r.refresh(); }; return ( {r.data ? (r.fromCache ? t('common.cachedFrom', { when: ago(r.at) }) : t('common.readWhen', { when: ago(r.at) })) : r.loading ? t('read.waiting') : t('sub.appleSub')}} @@ -90,7 +90,7 @@ export function AppleSubmitCard({ app, project }: { app: UnifiedApp; project: Pr
{t('sub.version')} - {version ? `${version.version} · ${version.state}` : {t('sub.noVersion')}} + {version ? `${version.version} · ${platformLabel(version.platform)} · ${version.state}` : {t('sub.noVersion')}}
{s.builds.length ? t('sub.buildHint') : t('sub.noBuilds')} diff --git a/apps/desktop/tests/submit.test.ts b/apps/desktop/tests/submit.test.ts index 2ce5f9c..ee88b62 100644 --- a/apps/desktop/tests/submit.test.ts +++ b/apps/desktop/tests/submit.test.ts @@ -20,7 +20,7 @@ describe('App Store submission state', () => { test('UNRESOLVED_ISSUES: its items with REJECTED marked, the held version is the one to resubmit', () => { const s = appleSubmitState(APPLE_STATUS_UNRESOLVED); expect(s.unresolved).toMatchObject({ id: 'rs-back-1', kind: 'unresolved' }); - expect(s.unresolved!.items.map(i => [i.label, i.state, i.rejected])).toEqual([['app version 2.4.0', 'REJECTED', true], ['in-app purchase iapv-coins', 'READY_FOR_REVIEW', false]]); + expect(s.unresolved!.items.map(i => [i.label, i.state, i.rejected])).toEqual([['app version 2.4.0 (iOS)', 'REJECTED', true], ['in-app purchase iapv-coins', 'READY_FOR_REVIEW', false]]); expect(s.editable).toMatchObject({ version: '2.4.0', state: 'REJECTED' }); expect(s.busy).toBeNull(); }); diff --git a/cli/apple/asc.ts b/cli/apple/asc.ts index badb721..cfa4d87 100644 --- a/cli/apple/asc.ts +++ b/cli/apple/asc.ts @@ -140,8 +140,9 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri return appId; } - async function editableVersion(): Promise { - 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 { + 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; } diff --git a/cli/apple/iap-submit.ts b/cli/apple/iap-submit.ts index d7952cd..a282826 100644 --- a/cli/apple/iap-submit.ts +++ b/cli/apple/iap-submit.ts @@ -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 { - 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 { + 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 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 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 { 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 { /** 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>; -async function planListing(ctx: Ctx, c: Asc, appId: string, version: string): Promise { +async function planListing(ctx: Ctx, c: Asc, appId: string, version: string, platform: AscPlatform): Promise { 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 { 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 { 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({ 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, idOf: v => v.id }, + journal: { root, key: `apple:${appId}:version:${p.platform}:${version}`, byId: id => getById(c.asc, `/v1/appStoreVersions/${id}`) as Promise, 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; diff --git a/cli/apple/platform.ts b/cli/apple/platform.ts new file mode 100644 index 0000000..ddc86dc --- /dev/null +++ b/cli/apple/platform.ts @@ -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 = { ios: 'IOS', macos: 'MAC_OS', tvos: 'TV_OS', visionos: 'VISION_OS' }; +const LABEL: Record = { 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 = { 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 { + return c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=${ASC_PLATFORMS.join(',')}&limit=200`); +} diff --git a/cli/apple/release.ts b/cli/apple/release.ts index f202633..53c1680 100644 --- a/cli/apple/release.ts +++ b/cli/apple/release.ts @@ -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 { const { config, root } = ctx.loaded; @@ -23,15 +25,20 @@ export async function run(ctx: Ctx): Promise { 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`); diff --git a/cli/apple/resubmit.ts b/cli/apple/resubmit.ts index dcf634e..051c9ec 100644 --- a/cli/apple/resubmit.ts +++ b/cli/apple/resubmit.ts @@ -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 [--version 1.0.0] [--resolve-iaps] [--dry] [--yes] [--json] +// storebound apple resubmit --build [--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 }); } diff --git a/cli/apple/screenshots.ts b/cli/apple/screenshots.ts index 2e7df65..3d484ab 100644 --- a/cli/apple/screenshots.ts +++ b/cli/apple/screenshots.ts @@ -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 = { ios69: 'APP_IPHONE_67', ios65: 'APP_IPHONE_65' }; @@ -83,8 +84,11 @@ export async function run(ctx: Ctx): Promise { 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); diff --git a/cli/apple/status.ts b/cli/apple/status.ts index c4808c1..09ece80 100644 --- a/cli/apple/status.ts +++ b/cli/apple/status.ts @@ -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 [--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 { const want = ctx.args.get('version'); @@ -31,12 +33,14 @@ async function waitReview(ctx: Ctx, c: Asc, appId: string): Promise { 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 { } 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(); 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 { 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 { } 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 ${newest ? ` (newest uploaded build: ${newest})` : ''}`); + console.log(` resubmit with: storebound apple resubmit --build ${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}`); diff --git a/cli/apple/submission.ts b/cli/apple/submission.ts index f182332..13bdfd7 100644 --- a/cli/apple/submission.ts +++ b/cli/apple/submission.ts @@ -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 { - 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 { + 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 { - return (await ensureDraft(c, appId, root, tag)).id; +export async function draftSubmission(c: Asc, appId: string, root: string, tag: string, platform = 'IOS'): Promise { + 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({ 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 { }); } -/** 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 { - 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 { + 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) }); diff --git a/cli/commands.ts b/cli/commands.ts index 24ae602..3bb9ee0 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -67,22 +67,22 @@ export const COMMANDS: Record Promise, 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 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 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 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 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 '], + '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 ; --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 )'], + 'apple status': [() => import('./apple/status'), 'versions, builds, IAPs, review submissions (--wait-review, --cancel-submission ); --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'], diff --git a/cli/data/flags.json b/cli/data/flags.json index 7343a21..1565902 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -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 [--version 1.0.0] [--resolve-iaps] [--dry] [--yes] [--json]" + "storebound apple resubmit --build [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--dry] [--yes] [--json]" ], "apple review-details": [ "storebound apple review-details [--contact-from ] [--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 [--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 \"\"] [--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" ] } } diff --git a/cli/lib/app-status.ts b/cli/lib/app-status.ts index 45e8016..bbb6db0 100644 --- a/cli/lib/app-status.ts +++ b/cli/lib/app-status.ts @@ -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; @@ -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); diff --git a/cli/lib/desktop-protocol.ts b/cli/lib/desktop-protocol.ts index 41d5baa..b988d91 100644 --- a/cli/lib/desktop-protocol.ts +++ b/cli/lib/desktop-protocol.ts @@ -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; diff --git a/cli/lib/listing-plan.ts b/cli/lib/listing-plan.ts index e1ea896..895fa64 100644 --- a/cli/lib/listing-plan.ts +++ b/cli/lib/listing-plan.ts @@ -58,6 +58,8 @@ export function compareVersions(a: string, b: string): number { return 0; } +const PLATFORM_LABEL: Record = { 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 }[]): 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 }[], want: string, attrs: { copyright: string; releaseType: string }): VersionPlan { +export function planVersion(rows: { id: string; attributes: Record }[], 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 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 ---- diff --git a/cli/lib/portfolio.ts b/cli/lib/portfolio.ts index 42def24..efa0693 100644 --- a/cli/lib/portfolio.ts +++ b/cli/lib/portfolio.ts @@ -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 = { 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; diff --git a/cli/lib/review-submit.ts b/cli/lib/review-submit.ts index 19df069..d108330 100644 --- a/cli/lib/review-submit.ts +++ b/cli/lib/review-submit.ts @@ -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 } } } }; diff --git a/cli/lib/validate-rules.ts b/cli/lib/validate-rules.ts index 3fd8938..61bd7c2 100644 --- a/cli/lib/validate-rules.ts +++ b/cli/lib/validate-rules.ts @@ -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 = { 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)'); diff --git a/cli/mcp-tools.ts b/cli/mcp-tools.ts index af09433..16c72d0 100644 --- a/cli/mcp-tools.ts +++ b/cli/mcp-tools.ts @@ -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-.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)' }, diff --git a/cli/serve.ts b/cli/serve.ts index ee05729..b0df36e 100644 --- a/cli/serve.ts +++ b/cli/serve.ts @@ -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 }, diff --git a/cli/validate.ts b/cli/validate.ts index 72aa93c..0383635 100644 --- a/cli/validate.ts +++ b/cli/validate.ts @@ -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 { 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 { 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), diff --git a/cli/whats-new.ts b/cli/whats-new.ts index 02e9f32..b68b835 100644 --- a/cli/whats-new.ts +++ b/cli/whats-new.ts @@ -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 { 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 .md files`); @@ -114,7 +117,8 @@ export async function run(ctx: Ctx): Promise { 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 ''; } })(); diff --git a/skills/release-management/SKILL.md b/skills/release-management/SKILL.md index 805af86..d95babe 100644 --- a/skills/release-management/SKILL.md +++ b/skills/release-management/SKILL.md @@ -30,6 +30,7 @@ presses release; you prepare, dry-run and report. `storebound mcp` deliberately `storebound apple listing --version 1.3.0 --build `. Never pick "the latest build": a CI or a teammate may have uploaded another one meanwhile. 2. `storebound apple release --phased on --dry`, then without `--dry` (works while the version is being prepared). + With versions on more than one App Store platform add `--platform ios|macos|tvos|visionos` (pitfall 77). 3. Submit (owner, UI). Wait: `storebound apple status --wait-review` (polls with backoff, exit 0 approved, 1 rejected, 7 timeout; `--timeout 72h`). Rejected: `review-rejections` skill. 4. Approved = PENDING_DEVELOPER_RELEASE. Owner releases: `storebound apple release --manual-release` (asks to confirm) diff --git a/skills/review-rejections/SKILL.md b/skills/review-rejections/SKILL.md index 2648094..e159632 100644 --- a/skills/review-rejections/SKILL.md +++ b/skills/review-rejections/SKILL.md @@ -54,6 +54,8 @@ in-app purchases). Resubmit that same submission; never create a second one next 2. `storebound apple status`: the submission with UNRESOLVED_ISSUES, its items and their states. 3. `storebound apple resubmit --build ` (dry run without `--yes`): the plan table shows the build to attach, the submission reused, every item in it already or added, which REJECTED items are marked resolved, and the submit. + An app with a macOS (tvOS, visionOS) version too: it picks the platform whose version was rejected, and asks for + `--platform ios|macos|...` when both were (pitfall 77). 4. The owner's yes, then `storebound apple resubmit --build --yes`: build n is attached by its number, missing items go in, the REJECTED version item is marked resolved (`PATCH reviewSubmissionItems/{id}` `resolved: true`), then it submits. Apple answers every submit with "Version is not ready to be submitted yet" (and the console's diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index be166fc..8a34ceb 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -938,3 +938,18 @@ that fails without the fix (see CONTRIBUTING.md). - **Guard:** `cli/apple/creative-assets.ts` (`classify`, `pickAsset`, `processingTiming`), `tests/creative-assets.test.ts` (fake App Store Connect: upload and select in one run, still processing, rejected / archived files, add-for-review without submitting, verify read-only, a refused live placement); the fake commits library assets to PREPARE_FOR_SUBMISSION, as Apple does. + +## 77. A macOS (tvOS, visionOS) version was invisible next to the iOS one +- **Symptom:** `apple resubmit` said "nothing to resubmit" while a macOS version was REJECTED with an + UNRESOLVED_ISSUES submission; `apple listing --version 0.8.7` refused because the iOS 0.8.7 was live, although the + macOS 0.8.7 was the rejected one to fix; `apple status` listed versions without saying which platform. +- **Cause:** one app record can have a version per platform, each with its own state and its own review submission + (`reviewSubmissions` carry a `platform`). Lookups filtered on `IOS` only, or did not filter at all and mixed them. +- **Do:** every version and review submission lookup filters by platform (`filter[platform]`). Commands that need one + version (status --wait-review, listing, listing pull, resubmit, submit, release, screenshots) take + `--platform ios|macos|tvos|visionos`; without it they use the platform that has the version they need, and refuse + naming both when several do. Status, validate, release status, the report and the desktop show the platform next to + every version; validate and release status warn about another platform's editable or rejected version. +- **Guard:** `cli/apple/platform.ts` (`pickPlatform`, `parsePlatform`), `tests/apple-platforms.test.ts` (fake App Store + Connect: iOS 0.8.7 live, macOS 0.8.7 rejected in UNRESOLVED_ISSUES). + diff --git a/tests/apple-platforms.test.ts b/tests/apple-platforms.test.ts new file mode 100644 index 0000000..8ce98aa --- /dev/null +++ b/tests/apple-platforms.test.ts @@ -0,0 +1,108 @@ +// One app record, two App Store platforms (pitfall 77). Seen live: `apple resubmit` said "nothing to resubmit" while +// the macOS version was REJECTED in an UNRESOLVED_ISSUES submission, and `apple listing --version 0.8.7` refused +// because the iOS 0.8.7 was live while the macOS 0.8.7 was rejected. Against the fake App Store Connect. +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; +import type { ShipConfig } from '../cli/lib/config'; +import { planVersion } from '../cli/lib/listing-plan'; +import { upsertTiming } from '../cli/lib/upsert'; +import { appleRules } from '../cli/lib/validate-rules'; +import { applePlatformWarnings, appleReleases } from '../cli/lib/portfolio'; +import { parsePlatform, pickPlatform, platformLabel } from '../cli/apple/platform'; +import { run as appleResubmit } from '../cli/apple/resubmit'; +import { run as appleStatus } from '../cli/apple/status'; +import { run as appleRelease } from '../cli/apple/release'; +import { ascEnv, BASE, capture, project, restore } from './helpers/store-mock'; +import { installStores, type Stores, uninstallStores } from './helpers/fake-stores'; + +const APP = '6400000101'; +const CONFIG: ShipConfig = { ...BASE }; +let s: Stores; +beforeAll(ascEnv); +afterAll(restore); +beforeEach(() => { upsertTiming.waitMs = 0; s = installStores(); }); +afterEach(uninstallStores); + +const ver = (id: string, platform: string, versionString: string, state: string) => + s.asc.put({ type: 'appStoreVersions', id, attributes: { versionString, appVersionState: state, platform }, rels: { app: APP } }); +/** iOS 0.8.7 live, macOS 0.8.7 rejected in an UNRESOLVED_ISSUES submission, build 8 uploaded. */ +function split(): void { + ver('v-ios', 'IOS', '0.8.7', 'READY_FOR_DISTRIBUTION'); + ver('v-mac', 'MAC_OS', '0.8.7', 'REJECTED'); + s.asc.put({ type: 'builds', id: 'b-8', attributes: { version: '8', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP } }); + s.asc.put({ type: 'reviewSubmissions', id: 'rs-mac', attributes: { platform: 'MAC_OS', state: 'UNRESOLVED_ISSUES' }, rels: { app: APP } }); + s.asc.put({ type: 'reviewSubmissionItems', id: 'it-mac', attributes: { state: 'REJECTED' }, rels: { reviewSubmission: 'rs-mac', appStoreVersion: 'v-mac' } }); +} + +describe('platform picking (pure)', () => { + const v = (platform: string, versionString: string, state: string) => ({ attributes: { platform, versionString, appVersionState: state } }); + const vs = [v('IOS', '0.8.7', 'READY_FOR_DISTRIBUTION'), v('MAC_OS', '0.8.7', 'REJECTED')]; + test('--platform values, labels', () => { + expect(parsePlatform('macos')).toBe('MAC_OS'); + expect(parsePlatform('MAC_OS')).toBe('MAC_OS'); + expect(parsePlatform('visionOS')).toBe('VISION_OS'); + expect(parsePlatform(undefined)).toBeUndefined(); + expect(() => parsePlatform('android')).toThrow(/ios \| macos/); + expect(platformLabel('MAC_OS')).toBe('macOS'); + }); + test('one platform has it -> that one; both -> refuse naming both; none -> the only platform, else iOS', () => { + const rejected = (x: typeof vs[0]) => x.attributes.appVersionState === 'REJECTED'; + expect(pickPlatform(vs, rejected, undefined, 'a rejected version')).toBe('MAC_OS'); + expect(pickPlatform(vs, rejected, 'IOS', 'a rejected version')).toBe('IOS'); + expect(() => pickPlatform([...vs, v('IOS', '0.8.8', 'REJECTED')], rejected, undefined, 'a rejected version')) + .toThrow(/a rejected version on more than one platform \(0\.8\.7 \(macOS\) REJECTED, 0\.8\.8 \(iOS\) REJECTED\): run it again with --platform macos or --platform ios/); + expect(pickPlatform([v('MAC_OS', '1.0', 'READY_FOR_SALE')], rejected, undefined, 'x')).toBe('MAC_OS'); + expect(pickPlatform(vs, () => false, undefined, 'x')).toBe('IOS'); + }); + test('listing --version 0.8.7: the macOS one is editable, so macOS; its plan updates it instead of refusing', () => { + const p = pickPlatform(vs, [x => x.attributes.versionString === '0.8.7' && x.attributes.appVersionState === 'REJECTED', () => true], undefined, 'x'); + expect(p).toBe('MAC_OS'); + const rows = [{ id: 'v-mac', attributes: { versionString: '0.8.7', appVersionState: 'REJECTED', platform: 'MAC_OS', copyright: '2026 Example', releaseType: 'MANUAL' } }]; + expect(planVersion(rows, '0.8.7', { copyright: '2026 Example', releaseType: 'MANUAL' }, 'MAC_OS').action).toBe('keep'); + const ios = [{ id: 'v-ios', attributes: { versionString: '0.8.7', appVersionState: 'READY_FOR_DISTRIBUTION', platform: 'IOS' } }]; + expect(planVersion(ios, '0.8.7', { copyright: 'c', releaseType: 'MANUAL' }, 'IOS').action).toBe('refuse'); + expect(planVersion([], '1.0.0', { copyright: 'c', releaseType: 'MANUAL' }, 'MAC_OS').attributes.platform).toBe('MAC_OS'); + }); + test('validate and release status name the other platform', () => { + const f = appleRules({ version: { versionString: '0.8.7', state: 'READY_FOR_DISTRIBUTION', platform: 'IOS' }, otherPlatformVersions: [{ versionString: '0.8.7', state: 'REJECTED', platform: 'MAC_OS' }], + build: null, expectedLocales: [], locales: [], requiredDisplayTypes: [], reviewDetail: null, ageRating: null, + priceSchedule: true, availability: true, iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 } }); + expect(f.find(x => x.code === 'version')!.msg).toBe('version 0.8.7 (iOS) is READY_FOR_DISTRIBUTION (nothing to prepare)'); + expect(f.find(x => x.code === 'version-platform')).toMatchObject({ level: 'warn', msg: 'version 0.8.7 (macOS) is REJECTED too: this report covers 0.8.7 (iOS) only' }); + const data = { versions: [{ id: 'v-ios', attributes: { platform: 'IOS', versionString: '0.8.7', appVersionState: 'READY_FOR_DISTRIBUTION' } }, { id: 'v-mac', attributes: { platform: 'MAC_OS', versionString: '0.8.7', appVersionState: 'REJECTED' } }] }; + expect(applePlatformWarnings(data)).toEqual(['App Store macOS 0.8.7: REJECTED (storebound apple status --platform macos)']); + expect(appleReleases(data).map(r => [r.version, r.platform])).toEqual([['0.8.7', 'IOS']]); + }); +}); + +describe('commands against an app with iOS live and macOS rejected', () => { + test('apple resubmit picks the macOS version and its UNRESOLVED_ISSUES submission', async () => { + split(); + const r = await capture(() => appleResubmit(project(CONFIG).ctx(['--build', '8', '--dry']))); + expect(r.out + r.err).toContain('app version 0.8.7 (macOS): REJECTED'); + expect(r.out).toMatch(/rs-mac\s+UNRESOLVED_ISSUES\s+reuse/); + }); + test('apple resubmit --platform ios: nothing on iOS, said per platform', async () => { + split(); + await expect(appleResubmit(project(CONFIG).ctx(['--build', '8', '--platform', 'ios', '--dry']))).rejects.toThrow(/no rejected iOS App Store version and no iOS review submission/); + }); + test('both platforms rejected: refuse and ask for --platform', async () => { + split(); + s.asc.all('appStoreVersions').find(x => x.id === 'v-ios')!.attributes.appVersionState = 'REJECTED'; + await expect(appleResubmit(project(CONFIG).ctx(['--build', '8', '--dry']))).rejects.toThrow(/more than one platform .*--platform (ios|macos) or --platform (ios|macos)/); + }); + test('apple status labels every version and submission with its platform; --platform shows one', async () => { + split(); + const r = await capture(() => appleStatus(project(CONFIG).ctx([]))); + expect(r.out).toMatch(/version 0\.8\.7\s+iOS\s+READY_FOR_DISTRIBUTION/); + expect(r.out).toMatch(/version 0\.8\.7\s+macOS\s+REJECTED/); + expect(r.out).toContain('review submission rs-mac (macOS) is back from App Review'); + expect(r.out).toContain('storebound apple resubmit --build --platform macos'); + const ios = await capture(() => appleStatus(project(CONFIG).ctx(['--platform', 'ios']))); + expect(ios.out).not.toContain('macOS'); + }); + test('apple release --phased on picks the platform with a version not yet live', async () => { + split(); + const r = await capture(() => appleRelease(project(CONFIG).ctx(['--phased', 'on', '--dry']))); + expect(r.out + r.err).toContain('0.8.7 macOS (REJECTED)'); + }); +}); diff --git a/tests/resubmit.test.ts b/tests/resubmit.test.ts index f3da4b5..f86d62a 100644 --- a/tests/resubmit.test.ts +++ b/tests/resubmit.test.ts @@ -230,7 +230,7 @@ describe('apple submit --build (a first submission, the desktop owner action s.asc.res.get('ver-1')!.attributes.appVersionState = 'READY_FOR_SALE'; const p = project(ONE); await expect(appleSubmit(p.ctx(['--build', '8', '--version', '1.0.0']))).rejects.toThrow(/READY_FOR_SALE: only a version in Prepare for Submission/); - await expect(appleSubmit(p.ctx(['--build', '8']))).rejects.toThrow(/no App Store version to submit/); + await expect(appleSubmit(p.ctx(['--build', '8']))).rejects.toThrow(/no iOS App Store version to submit/); }); }); @@ -381,18 +381,18 @@ describe('apple status', () => { rejected(); const p = project(CONFIG); const r = await capture(() => appleStatus(p.ctx([]))); - expect(r.out).toContain('review submission rs-old is back from App Review with UNRESOLVED_ISSUES'); - expect(r.out).toMatch(/app version 1\.0\.0\s+REJECTED/); + expect(r.out).toContain('review submission rs-old (iOS) is back from App Review with UNRESOLVED_ISSUES'); + expect(r.out).toMatch(/app version 1\.0\.0 \(iOS\)\s+REJECTED/); expect(r.out).toMatch(/inAppPurchaseVersion iapv-gems\s+READY_FOR_REVIEW/); expect(r.out).toContain('resubmit with: storebound apple resubmit --build '); - expect(r.out).toMatch(/app version 1\.0\.0\s+REJECTED \(not resolved/); + expect(r.out).toMatch(/app version 1\.0\.0 \(iOS\)\s+REJECTED \(not resolved/); }); test('an empty draft is labelled harmless; --cancel-submission on it answers 409 "not in cancellable state", left as is', async () => { s.asc.put({ type: 'reviewSubmissions', id: 'rs-empty', attributes: { platform: 'IOS', state: 'READY_FOR_REVIEW' }, rels: { app: APP } }); const p = project(CONFIG); const r = await capture(() => appleStatus(p.ctx([]))); - expect(r.out).toContain('review submission rs-empty READY_FOR_REVIEW: empty draft (harmless; App Store Connect keeps it, not cancellable)'); + expect(r.out).toContain('review submission rs-empty iOS READY_FOR_REVIEW: empty draft (harmless; App Store Connect keeps it, not cancellable)'); const c = await capture(() => appleStatus(p.ctx(['--cancel-submission', 'rs-empty', '--yes']))); expect([c.exit ?? 0, c.err]).toEqual([0, '']); expect(c.out).toContain('rs-empty is not cancellable');