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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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