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

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