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:
parent
1736300784
commit
47dfd372fe
30 changed files with 423 additions and 127 deletions
10
CHANGELOG.md
10
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
|
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.
|
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
|
## 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)
|
- **iPhone Duo screenshots.** `apple.iphoneDuo: true` adds the foldable's outer (1398 x 2034) and inner (2007 x 2853)
|
||||||
|
|
|
||||||
|
|
@ -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 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 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 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<string, string> = { 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 {
|
export interface AppleSubmitState {
|
||||||
submissions: SubmissionView[];
|
submissions: SubmissionView[];
|
||||||
/** The first submission back from App Review (UNRESOLVED_ISSUES), with its items. */
|
/** 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 d = obj(raw) as AppleStatusData & J;
|
||||||
const versions: AppleVersion[] = arr(d.versions).map(v => {
|
const versions: AppleVersion[] = arr(d.versions).map(v => {
|
||||||
const o = obj(v), a = obj(o.attributes);
|
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);
|
}).filter(v => v.id && v.version);
|
||||||
const label = (kind: string, versionId: string): string => {
|
const label = (kind: string, versionId: string): string => {
|
||||||
const v = versions.find(x => x.id === versionId);
|
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 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));
|
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 unresolved = submissions.find(s => s.kind === 'unresolved') ?? null;
|
||||||
const busy = submissions.find(s => s.kind === 'waiting' || s.kind === 'inReview') ?? 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 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 };
|
return { submissions, unresolved, busy, builds, editable, versions };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { useState } from 'react';
|
||||||
import { api, type Project } from '../../api';
|
import { api, type Project } from '../../api';
|
||||||
import { type Key, t } from '../../i18n';
|
import { type Key, t } from '../../i18n';
|
||||||
import { ago, date } from '../../lib/format';
|
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 type { UnifiedApp } from '../../lib/identity';
|
||||||
import { useData, useRead } from '../../store';
|
import { useData, useRead } from '../../store';
|
||||||
import { ConsolePlanButton } from '../../ui/ConsolePlan';
|
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 summary = d.statusOf(project)?.summary;
|
||||||
const iaps = summary?.catalog.ios.length ?? 0;
|
const iaps = summary?.catalog.ios.length ?? 0;
|
||||||
const picked = s?.builds.find(b => b.number === build && b.valid) ?? null;
|
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 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(); };
|
const refresh = () => { r.refresh(); };
|
||||||
return (
|
return (
|
||||||
<Card title={t('sub.appleTitle')} icon="rocket" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{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')}</span>}
|
<Card title={t('sub.appleTitle')} icon="rocket" sub={<span className="row" style={{ gap: 6 }}><StoreGlyph store="ios" className="ic13" />{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')}</span>}
|
||||||
|
|
@ -90,7 +90,7 @@ export function AppleSubmitCard({ app, project }: { app: UnifiedApp; project: Pr
|
||||||
</label>
|
</label>
|
||||||
<div className="stack small" style={{ gap: 4 }}>
|
<div className="stack small" style={{ gap: 4 }}>
|
||||||
<span className="strong">{t('sub.version')}</span>
|
<span className="strong">{t('sub.version')}</span>
|
||||||
<span>{version ? `${version.version} · ${version.state}` : <Chip tone="warn" icon="question">{t('sub.noVersion')}</Chip>}</span>
|
<span>{version ? `${version.version} · ${platformLabel(version.platform)} · ${version.state}` : <Chip tone="warn" icon="question">{t('sub.noVersion')}</Chip>}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span id="sub-build-hint" className="small muted">{s.builds.length ? t('sub.buildHint') : t('sub.noBuilds')}</span>
|
<span id="sub-build-hint" className="small muted">{s.builds.length ? t('sub.buildHint') : t('sub.noBuilds')}</span>
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
test('UNRESOLVED_ISSUES: its items with REJECTED marked, the held version is the one to resubmit', () => {
|
||||||
const s = appleSubmitState(APPLE_STATUS_UNRESOLVED);
|
const s = appleSubmitState(APPLE_STATUS_UNRESOLVED);
|
||||||
expect(s.unresolved).toMatchObject({ id: 'rs-back-1', kind: '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.editable).toMatchObject({ version: '2.4.0', state: 'REJECTED' });
|
||||||
expect(s.busy).toBeNull();
|
expect(s.busy).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -140,8 +140,9 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
|
||||||
return appId;
|
return appId;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function editableVersion(): Promise<J | null> {
|
/** The editable version of one platform (iOS unless named: an app's macOS version has its own state). */
|
||||||
const vs = await all(`/v1/apps/${await app()}/appStoreVersions?filter[platform]=IOS&limit=50`);
|
async function editableVersion(platform = 'IOS'): Promise<J | null> {
|
||||||
|
const vs = await all(`/v1/apps/${await app()}/appStoreVersions?filter[platform]=${platform}&limit=50`);
|
||||||
return vs.find(v => EDITABLE.has(v.attributes.appVersionState ?? v.attributes.appStoreState)) ?? null;
|
return vs.find(v => EDITABLE.has(v.attributes.appVersionState ?? v.attributes.appStoreState)) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,9 @@ import { addItem, draftJournalKey, ensureDraft, leaveUnusedDraft, openSubmission
|
||||||
|
|
||||||
const TAG = 'iap-submit';
|
const TAG = 'iap-submit';
|
||||||
|
|
||||||
export async function readSubmitState(c: Asc, appId: string, want: string[], o: { appVersionId?: string; also?: string } = {}): Promise<SubmitState> {
|
export async function readSubmitState(c: Asc, appId: string, want: string[], o: { appVersionId?: string; also?: string; platform?: string } = {}): Promise<SubmitState> {
|
||||||
const appVersions = (await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`)).map((v: J) => ({ id: v.id, versionString: v.attributes.versionString,
|
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 }));
|
state: v.attributes.appVersionState ?? v.attributes.appStoreState }));
|
||||||
const iaps: PIap[] = [];
|
const iaps: PIap[] = [];
|
||||||
for (const p of await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)) {
|
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`)) : [] });
|
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
|
// 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 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;
|
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 };
|
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;
|
appId: string; ids: string[]; state: SubmitState; tag: string; withVersion?: boolean;
|
||||||
/** the app version that must be in the submission (resubmit) */
|
/** the app version that must be in the submission (resubmit) */
|
||||||
appVersionId?: string;
|
appVersionId?: string;
|
||||||
|
/** the App Store platform of the version and the submission (default iOS) */
|
||||||
|
platform?: string;
|
||||||
/** the caller printed its own plan (resubmit) */
|
/** the caller printed its own plan (resubmit) */
|
||||||
quiet?: boolean;
|
quiet?: boolean;
|
||||||
/** REJECTED items marked resolved before the submit (resubmit: the version item; --resolve-iaps: the rest) */
|
/** REJECTED items marked resolved before the submit (resubmit: the version item; --resolve-iaps: the rest) */
|
||||||
|
|
@ -116,7 +119,7 @@ export async function submitPlanned(ctx: Ctx, c: Asc, o: SubmitOpts): Promise<vo
|
||||||
const a = ctx.args;
|
const a = ctx.args;
|
||||||
const dry = a.has('dry');
|
const dry = a.has('dry');
|
||||||
const { state, tag } = o;
|
const { state, tag } = o;
|
||||||
const plan = planIapSubmission(state, o.ids, { withVersion: o.withVersion, appVersionId: o.appVersionId });
|
const plan = planIapSubmission(state, o.ids, { withVersion: o.withVersion, appVersionId: o.appVersionId, platform: o.platform });
|
||||||
const prepare = plan.prepare.map(r => bindIds(r, { app: o.appId }));
|
const prepare = plan.prepare.map(r => bindIds(r, { app: o.appId }));
|
||||||
const submitting = willSubmit(state, plan);
|
const submitting = willSubmit(state, plan);
|
||||||
const busy = state.choice?.action === 'busy';
|
const busy = state.choice?.action === 'busy';
|
||||||
|
|
@ -155,7 +158,7 @@ export async function submitPlanned(ctx: Ctx, c: Asc, o: SubmitOpts): Promise<vo
|
||||||
// already in it, and an item the store says is in there already (409) is not added twice
|
// already in it, and an item the store says is in there already (409) is not added twice
|
||||||
let sid = state.draft?.id;
|
let sid = state.draft?.id;
|
||||||
let created = false;
|
let created = false;
|
||||||
if (!sid) ({ id: sid, created } = await ensureDraft(c, o.appId, root, tag));
|
if (!sid) ({ id: sid, created } = await ensureDraft(c, o.appId, root, tag, o.platform));
|
||||||
try {
|
try {
|
||||||
for (const r of prepare) {
|
for (const r of prepare) {
|
||||||
if (r.path === '/v1/reviewSubmissions') continue;
|
if (r.path === '/v1/reviewSubmissions') continue;
|
||||||
|
|
@ -175,7 +178,7 @@ export async function submitPlanned(ctx: Ctx, c: Asc, o: SubmitOpts): Promise<vo
|
||||||
if (o.retried || e.otherId === '?') throw e;
|
if (o.retried || e.otherId === '?') throw e;
|
||||||
// use the submission the 409 named: read the state again with it and go once more
|
// use the submission the 409 named: read the state again with it and go once more
|
||||||
log(tag, `using review submission ${e.otherId} instead`);
|
log(tag, `using review submission ${e.otherId} instead`);
|
||||||
const again = await readSubmitState(c, o.appId, o.ids, { appVersionId: o.appVersionId ?? plan.appVersion?.id, also: e.otherId });
|
const again = await readSubmitState(c, o.appId, o.ids, { appVersionId: o.appVersionId ?? plan.appVersion?.id, also: e.otherId, platform: o.platform });
|
||||||
for (const l of choiceLines(again)) log(tag, l);
|
for (const l of choiceLines(again)) log(tag, l);
|
||||||
await submitPlanned(ctx, c, { ...o, state: again, retried: true, quiet: true });
|
await submitPlanned(ctx, c, { ...o, state: again, retried: true, quiet: true });
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// App Store version + listing text (idempotent; re-run after editing the App Store listing file).
|
// 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
|
// 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
|
// 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
|
// (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 { done, log, printJson, warn } from '../lib/log';
|
||||||
import { ascClient, EDITABLE, type J, noAppInfo } from './asc';
|
import { ascClient, EDITABLE, type J, noAppInfo } from './asc';
|
||||||
import { stdinIsTTY } from '../lib/tty';
|
import { stdinIsTTY } from '../lib/tty';
|
||||||
|
import { allVersions, type AscPlatform, parsePlatform, pickPlatform, platformLabel, versionsPath } from './platform';
|
||||||
|
|
||||||
export function readAppleCopy(ctx: Ctx) {
|
export function readAppleCopy(ctx: Ctx) {
|
||||||
const { config, resolve } = ctx.loaded;
|
const { config, resolve } = ctx.loaded;
|
||||||
|
|
@ -38,11 +40,14 @@ async function pull(ctx: Ctx): Promise<void> {
|
||||||
const a = ctx.args;
|
const a = ctx.args;
|
||||||
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||||
const appId = await c.app();
|
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 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 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];
|
const v = want ? sorted.find((x: J) => x.attributes.versionString === want) : (await c.editableVersion(platform)) ?? sorted[0];
|
||||||
if (!v) throw new Error(want ? `no iOS version ${want} on App Store Connect` : 'no iOS version on App Store Connect yet');
|
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 infos = await c.all(`/v1/apps/${appId}/appInfos`);
|
||||||
const live = v.attributes.appVersionState ?? v.attributes.appStoreState;
|
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
|
// 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. */
|
/** The plan of one `apple listing --version` run, from read-only GETs. */
|
||||||
export interface AppleListingPlan {
|
export interface AppleListingPlan {
|
||||||
version: string;
|
version: string;
|
||||||
|
/** the App Store platform of the version (IOS, MAC_OS, ...) */
|
||||||
|
platform: AscPlatform;
|
||||||
versionPlan: VersionPlan;
|
versionPlan: VersionPlan;
|
||||||
build: { number: string; id: string | null; current: string | null; action: 'attach' | 'same' | 'refuse'; reason: string } | null;
|
build: { number: string; id: string | null; current: string | null; action: 'attach' | 'same' | 'refuse'; reason: string } | null;
|
||||||
appInfo: { id: string; state: string; locales: ItemPlan[] };
|
appInfo: { id: string; state: string; locales: ItemPlan[] };
|
||||||
|
|
@ -90,7 +97,7 @@ export interface AppleListingPlan {
|
||||||
|
|
||||||
type Asc = Awaited<ReturnType<typeof ascClient>>;
|
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 { config, resolve } = ctx.loaded;
|
||||||
const app = config.app;
|
const app = config.app;
|
||||||
const listing = readAppleCopy(ctx);
|
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]; }
|
else { skipped.push({ key: l, reason: draftReason(st) }); delete listing[l]; }
|
||||||
}
|
}
|
||||||
const copyright = app.copyright ?? `${new Date().getFullYear()} ${app.name}`;
|
const copyright = app.copyright ?? `${new Date().getFullYear()} ${app.name}`;
|
||||||
const rows = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`);
|
const rows = await c.all(versionsPath(appId, platform));
|
||||||
const versionPlan = planVersion(rows, version, { copyright, releaseType: 'MANUAL' });
|
const versionPlan = planVersion(rows, version, { copyright, releaseType: 'MANUAL' }, platform);
|
||||||
|
|
||||||
let build: AppleListingPlan['build'] = null;
|
let build: AppleListingPlan['build'] = null;
|
||||||
const number = ctx.args.get('build');
|
const number = ctx.args.get('build');
|
||||||
if (number) {
|
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 cur = versionPlan.id ? (await c.maybe(`/v1/appStoreVersions/${versionPlan.id}/build`))?.data : null;
|
||||||
const current = cur ? String(cur.attributes?.version ?? cur.id) : 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)` }
|
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)
|
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);
|
+ 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 };
|
versionLocales, wanted, skipped, notes, changes };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The plan as text lines. Pure. */
|
/** The plan as text lines. Pure. */
|
||||||
export function listingPlanLines(p: AppleListingPlan): string[] {
|
export function listingPlanLines(p: AppleListingPlan): string[] {
|
||||||
const v = p.versionPlan;
|
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}`);
|
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)}`}`);
|
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}`);
|
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 dry = a.has('dry');
|
||||||
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||||
const appId = await c.app();
|
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);
|
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 });
|
if (a.has('json')) printJson({ dry, ...p, refused });
|
||||||
else for (const line of listingPlanLines(p)) console.log(line);
|
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') {
|
if (vp.action === 'create') {
|
||||||
// look again right before the create (another run, or a lost answer), then the journal for the list lag
|
// 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,
|
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,
|
.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',
|
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 });
|
attributes: Object.fromEntries(Object.entries(vp.attributes)), relationships: { app: { data: { type: 'apps', id: appId } } } } })).data });
|
||||||
versionId = made.value.id;
|
versionId = made.value.id;
|
||||||
|
|
|
||||||
61
cli/apple/platform.ts
Normal file
61
cli/apple/platform.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
// App Store platforms of one app record. An app can have an iOS and a macOS (tvOS, visionOS) version at the same time,
|
||||||
|
// each with its own state and its own review submission (pitfall 77): every version lookup filters by platform, and a
|
||||||
|
// command that needs one version picks the platform that has it, or asks for --platform when several do.
|
||||||
|
import type { Asc, J } from './asc';
|
||||||
|
|
||||||
|
export type AscPlatform = 'IOS' | 'MAC_OS' | 'TV_OS' | 'VISION_OS';
|
||||||
|
export const ASC_PLATFORMS: AscPlatform[] = ['IOS', 'MAC_OS', 'TV_OS', 'VISION_OS'];
|
||||||
|
const FLAG: Record<string, AscPlatform> = { ios: 'IOS', macos: 'MAC_OS', tvos: 'TV_OS', visionos: 'VISION_OS' };
|
||||||
|
const LABEL: Record<AscPlatform, string> = { IOS: 'iOS', MAC_OS: 'macOS', TV_OS: 'tvOS', VISION_OS: 'visionOS' };
|
||||||
|
/** The console's URL segment of a platform's version page. */
|
||||||
|
export const CONSOLE_SEGMENT: Record<AscPlatform, string> = { IOS: 'ios', MAC_OS: 'macos', TV_OS: 'appletvos', VISION_OS: 'visionos' };
|
||||||
|
|
||||||
|
/** --platform ios|macos|tvos|visionos (also the API names IOS, MAC_OS, ...) -> the API's platform; undefined when not
|
||||||
|
* given; throws on anything else. Pure. */
|
||||||
|
export function parsePlatform(v: string | undefined): AscPlatform | undefined {
|
||||||
|
if (v == null || v === '') return undefined;
|
||||||
|
const k = v.toLowerCase().replace(/[_\s-]/g, '');
|
||||||
|
const p = FLAG[k] ?? FLAG[k.replace(/^appletv/, 'tv')];
|
||||||
|
if (!p) throw new Error(`--platform: ios | macos | tvos | visionos (got "${v}")`);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "iOS", "macOS", ... for any platform string (unknown ones as they are). Pure. */
|
||||||
|
export const platformLabel = (p: string | undefined | null): string => LABEL[(p ?? 'IOS') as AscPlatform] ?? String(p);
|
||||||
|
|
||||||
|
export const platformOf = (v: J): AscPlatform => (v?.attributes?.platform ?? 'IOS') as AscPlatform;
|
||||||
|
export const stateOf = (v: J): string => v?.attributes?.appVersionState ?? v?.attributes?.appStoreState ?? '';
|
||||||
|
/** "0.8.7 (macOS)": a version as people read it. */
|
||||||
|
export const versionLabel = (v: J): string => `${v?.attributes?.versionString ?? '?'} (${platformLabel(platformOf(v))})`;
|
||||||
|
|
||||||
|
/** The appStoreVersions list path of one platform. */
|
||||||
|
export const versionsPath = (appId: string, platform: AscPlatform, limit = 50): string =>
|
||||||
|
`/v1/apps/${appId}/appStoreVersions?filter[platform]=${platform}&limit=${limit}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The platform a command works on. `flag` (--platform) wins. Else the platforms that have a version `needs` accepts:
|
||||||
|
* one -> that one; several -> an error naming them all (ask for --platform); none -> the only platform the app has
|
||||||
|
* versions on, else iOS. Pure.
|
||||||
|
*/
|
||||||
|
export function pickPlatform(versions: J[], needs: ((v: J) => boolean) | ((v: J) => boolean)[], flag: AscPlatform | undefined, what: string): AscPlatform {
|
||||||
|
if (flag) return flag;
|
||||||
|
// several tests: the first one any version passes decides (e.g. "this version string, editable", then "editable")
|
||||||
|
if (Array.isArray(needs)) {
|
||||||
|
for (const n of needs) if (versions.some(n)) return pickPlatform(versions, n, undefined, what);
|
||||||
|
return pickPlatform(versions, () => false, undefined, what);
|
||||||
|
}
|
||||||
|
const hits = [...new Set(versions.filter(needs).map(platformOf))];
|
||||||
|
if (hits.length === 1) return hits[0]!;
|
||||||
|
if (hits.length > 1) {
|
||||||
|
const which = versions.filter(needs).map(v => `${versionLabel(v)} ${stateOf(v)}`).join(', ');
|
||||||
|
throw new Error(`${what} on more than one platform (${which}): run it again with --platform ${hits.map(p => Object.keys(FLAG).find(k => FLAG[k] === p)).join(' or --platform ')}`);
|
||||||
|
}
|
||||||
|
const all = [...new Set(versions.map(platformOf))];
|
||||||
|
return all.length === 1 ? all[0]! : 'IOS';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every App Store version of the app on every platform (only to pick the platform; the command then reads its
|
||||||
|
* platform's list with filter[platform]). */
|
||||||
|
export async function allVersions(c: Asc, appId: string): Promise<J[]> {
|
||||||
|
return c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=${ASC_PLATFORMS.join(',')}&limit=200`);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Release management for an App Store version.
|
// Release management for an App Store version.
|
||||||
// storebound apple release --phased on|pause|resume|complete [--version 1.2.0] [--dry] [--yes]
|
// storebound apple release --phased on|pause|resume|complete [--version 1.2.0] [--dry] [--yes]
|
||||||
// storebound apple release --manual-release [--version 1.2.0] [--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
|
// 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
|
// 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.
|
// 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 { type PhasedAction, phasedRequest } from '../lib/release';
|
||||||
import { createOnce } from '../lib/upsert';
|
import { createOnce } from '../lib/upsert';
|
||||||
import { ascClient, type J } from './asc';
|
import { ascClient, type J } from './asc';
|
||||||
|
import { allVersions, parsePlatform, pickPlatform, platformLabel, versionsPath } from './platform';
|
||||||
|
|
||||||
export async function run(ctx: Ctx): Promise<void> {
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
const { config, root } = ctx.loaded;
|
const { config, root } = ctx.loaded;
|
||||||
|
|
@ -23,15 +25,20 @@ export async function run(ctx: Ctx): Promise<void> {
|
||||||
const dry = a.has('dry');
|
const dry = a.has('dry');
|
||||||
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||||
const appId = await c.app();
|
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 want = a.get('version');
|
||||||
const state = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState;
|
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)
|
const v: J | undefined = want ? versions.find(x => x.attributes.versionString === want)
|
||||||
: manual ? versions.find(x => state(x) === 'PENDING_DEVELOPER_RELEASE')
|
: 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)))
|
: 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];
|
: 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');
|
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} (${state(v)})`;
|
const tag = `${v.attributes.versionString} ${platformLabel(platform)} (${state(v)})`;
|
||||||
|
|
||||||
if (manual) {
|
if (manual) {
|
||||||
if (state(v) !== 'PENDING_DEVELOPER_RELEASE') throw new Error(`${tag}: only an approved version waiting for developer release can be released`);
|
if (state(v) !== 'PENDING_DEVELOPER_RELEASE') throw new Error(`${tag}: only an approved version waiting for developer release can be released`);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// Resubmit an App Store version after a rejection (Invalid Binary, Guideline 2.1, ...) with a new build.
|
// Resubmit an App Store version after a rejection (Invalid Binary, Guideline 2.1, ...) with a new build.
|
||||||
// storebound apple resubmit --build <n> [--version 1.0.0] [--resolve-iaps] [--dry] [--yes] [--json]
|
// storebound apple resubmit --build <n> [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--dry] [--yes] [--json]
|
||||||
|
// The platform: --platform, else the one with a rejected version or an UNRESOLVED_ISSUES submission (both: asks).
|
||||||
// 1. The rejected version (--version, else the one in the submission back from App Review, else the one in a
|
// 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.
|
// 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
|
// 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 { ascClient, type J } from './asc';
|
||||||
import { readSubmitState, submitPlanned } from './iap-submit';
|
import { readSubmitState, submitPlanned } from './iap-submit';
|
||||||
import { openSubmissions } from './submission';
|
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
|
/** 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). */
|
* 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 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)];
|
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
|
// the platform: --platform, else the one whose version (or UNRESOLVED_ISSUES submission) this needs; both -> ask
|
||||||
const versions = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`);
|
|
||||||
const stateOf = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState;
|
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 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)
|
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)));
|
: 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`
|
if (!v) throw new Error(want ? `no App Store version ${want} on ${pl}`
|
||||||
: mode === 'submit' ? 'no App Store version to submit (none in Prepare for Submission or rejected): storebound apple listing --version x.y.z first'
|
: 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 App Store version and no review submission with UNRESOLVED_ISSUES: nothing to resubmit (storebound apple status)');
|
: `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)} is ${stateOf(v)}: only a version in Prepare for Submission (or a rejected one) can be submitted`);
|
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);
|
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
|
// 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 cur = (await c.maybe(`/v1/appStoreVersions/${v.id}/build`))?.data;
|
||||||
const current = cur ? String(cur.attributes?.version ?? cur.id) : null;
|
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)` }
|
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}` : ''}` };
|
: { 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)`);
|
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 state = await readSubmitState(c, appId, ids, { appVersionId: v.id, platform });
|
||||||
const plan = planIapSubmission(state, ids, { withVersion: true, appVersionId: v.id });
|
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
|
// --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 resolve: ResolveScope = { version: true, iaps: a.has('resolve-iaps') };
|
||||||
const rows = resubmitRows(state, plan, build, resolve);
|
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 blockers = [...(build.action === 'refuse' ? [build.reason] : []), ...plan.blockers];
|
||||||
const dry = a.has('dry') || !a.has('yes');
|
const dry = a.has('dry') || !a.has('yes');
|
||||||
if (a.has('json')) {
|
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
|
// 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 (process.env.STOREBOUND_MACHINE === '1') { console.log(formatRows(rows)); for (const x of blockers) warn(TAG, x); }
|
||||||
if (blockers.length) process.exitCode = 1;
|
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 } });
|
await c.asc('PATCH', `/v1/appStoreVersions/${v.id}/relationships/build`, { data: { type: 'builds', id: build.id } });
|
||||||
log(TAG, `build ${number} attached to ${versionString}`);
|
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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@ import { findOrCreate } from '../lib/upsert';
|
||||||
import { pulledChecksum } from '../lib/art-pull';
|
import { pulledChecksum } from '../lib/art-pull';
|
||||||
import { consoleLink } from '../lib/console-links';
|
import { consoleLink } from '../lib/console-links';
|
||||||
import { IPHONE_DUO_DISPLAY, IPHONE_DUO_NOT_IN_API } from '../shots/spec';
|
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). */
|
/** Render size key -> ASC screenshotDisplayType (iPhone sets). */
|
||||||
export const DISPLAY: Record<string, string> = { ios69: 'APP_IPHONE_67', ios65: 'APP_IPHONE_65' };
|
export const DISPLAY: Record<string, string> = { ios69: 'APP_IPHONE_67', ios65: 'APP_IPHONE_65' };
|
||||||
|
|
@ -83,8 +84,11 @@ export async function run(ctx: Ctx): Promise<void> {
|
||||||
const only = ctx.args.list('only');
|
const only = ctx.args.list('only');
|
||||||
const jobs = ctx.args.num('jobs', 4);
|
const jobs = ctx.args.num('jobs', 4);
|
||||||
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||||
const version = await c.editableVersion();
|
// the platform: --platform, else the one with an editable version (both: asks)
|
||||||
if (!version) throw new Error('no editable App Store version (run apple listing --version x.y.z first)');
|
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);
|
const duoSkip = skippedDuoSets(config.apple);
|
||||||
if (duoSkip) warn('apple', duoSkip.msg);
|
if (duoSkip) warn('apple', duoSkip.msg);
|
||||||
const langOf = byAppleLocale(config.languages);
|
const langOf = byAppleLocale(config.languages);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// Read-only overview: app record, versions + attached build, recent builds, in-app purchases, review submissions.
|
// 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):
|
// And the one write it offers: cancel a review submission (to re-add first IAPs to it from the web UI):
|
||||||
// storebound apple status --cancel-submission <id> [--yes]
|
// storebound apple status --cancel-submission <id> [--yes]
|
||||||
// An empty draft (READY_FOR_REVIEW, 0 items) cannot be canceled (409 "Resource is not in cancellable state"); it is
|
// 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 { notCancellable, readItems, type SubItem } from './submission';
|
||||||
import { capabilityReport, type CapabilityReport, printCapabilityReport } from './signing';
|
import { capabilityReport, type CapabilityReport, printCapabilityReport } from './signing';
|
||||||
import * as rt from '../lib/rt';
|
import * as rt from '../lib/rt';
|
||||||
|
import { allVersions, parsePlatform, pickPlatform, platformLabel, platformOf, versionLabel, versionsPath } from './platform';
|
||||||
|
|
||||||
async function waitReview(ctx: Ctx, c: Asc, appId: string): Promise<never> {
|
async function waitReview(ctx: Ctx, c: Asc, appId: string): Promise<never> {
|
||||||
const want = ctx.args.get('version');
|
const want = ctx.args.get('version');
|
||||||
|
|
@ -31,12 +33,14 @@ async function waitReview(ctx: Ctx, c: Asc, appId: string): Promise<never> {
|
||||||
let delay: number | null = null;
|
let delay: number | null = null;
|
||||||
let last = '';
|
let last = '';
|
||||||
for (;;) {
|
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];
|
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); }
|
if (!v) { log('apple', `no version ${want ?? ''} found`); process.exit(2); }
|
||||||
const st = state(v);
|
const st = state(v);
|
||||||
const outcome = classifyReviewState(st);
|
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 === '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 === '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); }
|
if (outcome === 'not-submitted') { log('apple', 'nothing is waiting for review'); process.exit(2); }
|
||||||
|
|
@ -70,15 +74,17 @@ export async function run(ctx: Ctx): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = (await c.asc('GET', `/v1/apps/${appId}`)).data;
|
const 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 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 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 };
|
let caps: CapabilityReport | { error: string };
|
||||||
try { caps = await capabilityReport(c, config, root); } catch (e) { caps = { error: (e as Error).message.slice(0, 300) }; }
|
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
|
// a submission back from App Review: its items and their states, and how to resubmit it
|
||||||
const unresolved: { id: string; items: SubItem[] }[] = [];
|
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, items: await readItems(c, s.id) });
|
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
|
// an empty draft: App Store Connect keeps it and refuses to cancel it; harmless
|
||||||
const empty = new Set<string>();
|
const empty = new Set<string>();
|
||||||
for (const s of subs.filter((x: J) => x.attributes?.state === 'READY_FOR_REVIEW')) if (!(await readItems(c, s.id)).length) empty.add(s.id);
|
for (const s of subs.filter((x: J) => x.attributes?.state === 'READY_FOR_REVIEW')) if (!(await readItems(c, s.id)).length) empty.add(s.id);
|
||||||
|
|
@ -89,7 +95,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
||||||
try { gaps = await firstProductReport(c, appId, { iaps, versionState: reviewed ? vstate(reviewed) : undefined }); } catch (e) { log('apple', `first in-app purchases not compared with the review: ${(e as Error).message.slice(0, 200)}`); }
|
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 itemLabel = (kind: string, id: string): string => {
|
||||||
const v = versions.find((x: J) => x.id === id);
|
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')) {
|
if (ctx.args.has('json')) {
|
||||||
// phased release of the newest versions that are live or close to it (rollout share on the desktop timeline)
|
// phased release of the newest versions that are live or close to it (rollout share on the desktop timeline)
|
||||||
|
|
@ -115,16 +121,16 @@ export async function run(ctx: Ctx): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`${app.attributes.name} · ${app.attributes.bundleId} · Apple ID ${appId}`);
|
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]));
|
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 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 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) {
|
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)' : ''}`);
|
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;
|
const newest = builds.data[0]?.attributes?.version;
|
||||||
console.log(` resubmit with: storebound apple resubmit --build <n>${newest ? ` (newest uploaded build: ${newest})` : ''}`);
|
console.log(` resubmit with: storebound apple resubmit --build <n>${u.platform === 'IOS' ? '' : ` --platform ${platformLabel(u.platform).toLowerCase()}`}${newest ? ` (newest uploaded build: ${newest})` : ''}`);
|
||||||
}
|
}
|
||||||
for (const g of gaps) warn('apple', `${g.msg}; ${g.fix}`);
|
for (const g of gaps) warn('apple', `${g.msg}; ${g.fix}`);
|
||||||
if ('error' in caps) log('apple', `capabilities not compared: ${caps.error}`);
|
if ('error' in caps) log('apple', `capabilities not compared: ${caps.error}`);
|
||||||
|
|
|
||||||
|
|
@ -9,26 +9,26 @@ import type { Asc, J } from './asc';
|
||||||
import { one } from './jsonapi';
|
import { one } from './jsonapi';
|
||||||
import * as rt from '../lib/rt';
|
import * as rt from '../lib/rt';
|
||||||
|
|
||||||
/** The draft review submission of the app (READY_FOR_REVIEW, iOS), or 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): Promise<string | null> {
|
export async function findDraft(c: Asc, appId: string, platform = 'IOS'): Promise<string | null> {
|
||||||
return (await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&filter[platform]=IOS&filter[state]=READY_FOR_REVIEW&limit=10`))[0]?.id ?? null;
|
return (await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&filter[platform]=${platform}&filter[state]=READY_FOR_REVIEW&limit=10`))[0]?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The draft's id: the existing one, else a new draft created once. */
|
/** The draft's id: the existing one, else a new draft created once. */
|
||||||
export async function draftSubmission(c: Asc, appId: string, root: string, tag: string): Promise<string> {
|
export async function draftSubmission(c: Asc, appId: string, root: string, tag: string, platform = 'IOS'): Promise<string> {
|
||||||
return (await ensureDraft(c, appId, root, tag)).id;
|
return (await ensureDraft(c, appId, root, tag, platform)).id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The journal key of the draft storebound created (cli/lib/upsert.ts). */
|
/** 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). */
|
/** Like draftSubmission, and says whether this run created the draft (an unused empty one is left: not cancellable). */
|
||||||
export async function ensureDraft(c: Asc, appId: string, root: string, tag: string): Promise<{ id: string; created: boolean }> {
|
export async function ensureDraft(c: Asc, appId: string, root: string, tag: string, platform = 'IOS'): Promise<{ id: string; created: boolean }> {
|
||||||
const r = await findOrCreate<string>({ what: 'draft review submission', tag, unique: true,
|
const r = await findOrCreate<string>({ what: 'draft review submission', tag, unique: true,
|
||||||
find: () => findDraft(c, appId),
|
find: () => findDraft(c, appId, platform),
|
||||||
journal: { root, key: draftJournalKey(appId), idOf: id => id,
|
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; } },
|
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`);
|
if (r.created) log(tag, `draft review submission ${r.value} created`);
|
||||||
return { id: r.value, created: r.created };
|
return { id: r.value, created: r.created };
|
||||||
}
|
}
|
||||||
|
|
@ -81,10 +81,10 @@ export async function readItems(c: Asc, sid: string): Promise<SubItem[]> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every open review submission of the app on iOS (OPEN_STATES), each with its items. `also`: a submission id a 409
|
/** Every open review submission of the app on one platform (iOS unless named; OPEN_STATES), each with its items.
|
||||||
* named, read directly (a list may lag behind, or its state may be outside the filter). */
|
* `also`: a submission id a 409 named, read directly (a list may lag behind, or its state may be outside the filter). */
|
||||||
export async function openSubmissions(c: Asc, appId: string, also?: string): Promise<OpenSubmission[]> {
|
export async function openSubmissions(c: Asc, appId: string, also?: string, platform = 'IOS'): Promise<OpenSubmission[]> {
|
||||||
const subs = await c.all(`/v1/apps/${appId}/reviewSubmissions?filter[platform]=IOS&filter[state]=${OPEN_STATES.join(',')}&include=items&limit=50`);
|
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); }
|
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[] = [];
|
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) });
|
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) });
|
||||||
|
|
|
||||||
|
|
@ -67,22 +67,22 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
||||||
'apple subscriptions': [() => import('./apple/subscriptions'), 'subscription groups, subscriptions, localizations, prices (equalized + overrides + --csv), intro offers, availability, review screenshot (asks first)'],
|
'apple 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-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 iap-submit': [() => import('./apple/iap-submit'), 'IAP + subscription versions into a review submission (first of a type: --with-version), then submit (asks first; --no-submit keeps a draft); reuses the open submission, never a second one'],
|
||||||
'apple submit': [() => import('./apple/submit'), 'a version to App Review: --build <n> attached (by number, VALID), the open submission reused, the ready IAPs / subscriptions with it, submitted with retry (dry run unless --yes)'],
|
'apple submit': [() => import('./apple/submit'), 'a version to App Review: --build <n> attached (by number, VALID), the open submission reused, the ready IAPs / subscriptions with it, submitted with retry (dry run unless --yes); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
|
||||||
'apple resubmit': [() => import('./apple/resubmit'), 'after a rejection: --build <n> attached to the rejected version, the UNRESOLVED_ISSUES submission reused (missing items added), resubmitted with retry (dry run unless --yes)'],
|
'apple resubmit': [() => import('./apple/resubmit'), 'after a rejection: --build <n> attached to the rejected version, the UNRESOLVED_ISSUES submission reused (missing items added), resubmitted with retry (dry run unless --yes); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
|
||||||
'apple offer-codes': [() => import('./apple/offer-codes'), 'offer codes for one-time purchases (campaigns; custom and one-time-use codes with --codes, asks first)'],
|
'apple 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 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 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 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 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 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 listing': [() => import('./apple/listing'), 'version, build, name / subtitle / description / keywords per locale; pull [--write]: live texts back into the listing file (diff first); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
|
||||||
'apple screenshots': [() => import('./apple/screenshots'), '6.9" + 6.5" (+ 13" iPad) screenshots per locale; --variant <id>'],
|
'apple screenshots': [() => import('./apple/screenshots'), '6.9" + 6.5" (+ 13" iPad) screenshots per locale; --variant <id>; --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
|
||||||
'apple appinfo': [() => import('./apple/appinfo'), 'categories, age rating, content rights, Free price, territories'],
|
'apple 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 review-details': [() => import('./apple/review-details'), 'App Review contact, demo account (from .storebound/, masked) + notes'],
|
||||||
'apple status': [() => import('./apple/status'), 'versions, builds, IAPs, review submissions (--wait-review, --cancel-submission <id>)'],
|
'apple status': [() => import('./apple/status'), 'versions, builds, IAPs, review submissions (--wait-review, --cancel-submission <id>); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
|
||||||
'apple creative-assets': [() => import('./apple/creative-assets'), 'Header and Search Results: product page header (21:9) and search results asset (3:2) per locale, or one universal 5244x2950 PNG; uploads to the Asset Library and selects it once processed (MD5 upsert); --add-for-review, --verify (read-only), --dry'],
|
'apple 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 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 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 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'],
|
'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'],
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@
|
||||||
"name",
|
"name",
|
||||||
"not-sent-for-review",
|
"not-sent-for-review",
|
||||||
"off",
|
"off",
|
||||||
|
"platform",
|
||||||
"prune",
|
"prune",
|
||||||
"src",
|
"src",
|
||||||
"variant",
|
"variant",
|
||||||
|
|
@ -109,6 +110,7 @@
|
||||||
"apple listing": [
|
"apple listing": [
|
||||||
"build",
|
"build",
|
||||||
"include-drafts",
|
"include-drafts",
|
||||||
|
"platform",
|
||||||
"version",
|
"version",
|
||||||
"write"
|
"write"
|
||||||
],
|
],
|
||||||
|
|
@ -143,6 +145,7 @@
|
||||||
"jobs",
|
"jobs",
|
||||||
"name",
|
"name",
|
||||||
"not-sent-for-review",
|
"not-sent-for-review",
|
||||||
|
"platform",
|
||||||
"prune",
|
"prune",
|
||||||
"src",
|
"src",
|
||||||
"traffic",
|
"traffic",
|
||||||
|
|
@ -168,12 +171,14 @@
|
||||||
"apple release": [
|
"apple release": [
|
||||||
"manual-release",
|
"manual-release",
|
||||||
"phased",
|
"phased",
|
||||||
|
"platform",
|
||||||
"version"
|
"version"
|
||||||
],
|
],
|
||||||
"apple resubmit": [
|
"apple resubmit": [
|
||||||
"build",
|
"build",
|
||||||
"new-version",
|
"new-version",
|
||||||
"no-submit",
|
"no-submit",
|
||||||
|
"platform",
|
||||||
"resolve-iaps",
|
"resolve-iaps",
|
||||||
"version",
|
"version",
|
||||||
"with-version"
|
"with-version"
|
||||||
|
|
@ -185,6 +190,7 @@
|
||||||
"include-drafts",
|
"include-drafts",
|
||||||
"jobs",
|
"jobs",
|
||||||
"not-sent-for-review",
|
"not-sent-for-review",
|
||||||
|
"platform",
|
||||||
"prune",
|
"prune",
|
||||||
"src",
|
"src",
|
||||||
"variant",
|
"variant",
|
||||||
|
|
@ -198,6 +204,7 @@
|
||||||
"cancel-submission",
|
"cancel-submission",
|
||||||
"keychain",
|
"keychain",
|
||||||
"no-install",
|
"no-install",
|
||||||
|
"platform",
|
||||||
"timeout",
|
"timeout",
|
||||||
"version",
|
"version",
|
||||||
"wait-review"
|
"wait-review"
|
||||||
|
|
@ -206,6 +213,7 @@
|
||||||
"build",
|
"build",
|
||||||
"new-version",
|
"new-version",
|
||||||
"no-submit",
|
"no-submit",
|
||||||
|
"platform",
|
||||||
"resolve-iaps",
|
"resolve-iaps",
|
||||||
"version",
|
"version",
|
||||||
"with-version"
|
"with-version"
|
||||||
|
|
@ -860,6 +868,7 @@
|
||||||
"no-scan",
|
"no-scan",
|
||||||
"not-sent-for-review",
|
"not-sent-for-review",
|
||||||
"out",
|
"out",
|
||||||
|
"platform",
|
||||||
"play",
|
"play",
|
||||||
"prune",
|
"prune",
|
||||||
"react-native",
|
"react-native",
|
||||||
|
|
@ -885,6 +894,7 @@
|
||||||
"hook",
|
"hook",
|
||||||
"lang",
|
"lang",
|
||||||
"languages",
|
"languages",
|
||||||
|
"platform",
|
||||||
"release",
|
"release",
|
||||||
"since",
|
"since",
|
||||||
"translate"
|
"translate"
|
||||||
|
|
@ -1206,7 +1216,7 @@
|
||||||
"storebound apple iap-submit [--only id1,id2] [--with-version] [--new-version] [--resolve-iaps] [--no-submit] [--dry] [--yes] [--json]"
|
"storebound apple iap-submit [--only id1,id2] [--with-version] [--new-version] [--resolve-iaps] [--no-submit] [--dry] [--yes] [--json]"
|
||||||
],
|
],
|
||||||
"apple listing": [
|
"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]"
|
"storebound apple listing pull [--version x.y.z] [--write] [--json]"
|
||||||
],
|
],
|
||||||
"apple next-build": [
|
"apple next-build": [
|
||||||
|
|
@ -1237,7 +1247,7 @@
|
||||||
"storebound apple release --manual-release [--version 1.2.0] [--yes]"
|
"storebound apple release --manual-release [--version 1.2.0] [--yes]"
|
||||||
],
|
],
|
||||||
"apple resubmit": [
|
"apple resubmit": [
|
||||||
"storebound apple resubmit --build <n> [--version 1.0.0] [--resolve-iaps] [--dry] [--yes] [--json]"
|
"storebound apple resubmit --build <n> [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--dry] [--yes] [--json]"
|
||||||
],
|
],
|
||||||
"apple review-details": [
|
"apple review-details": [
|
||||||
"storebound apple review-details [--contact-from <Apple ID of another app>] [--dry]"
|
"storebound apple review-details [--contact-from <Apple ID of another app>] [--dry]"
|
||||||
|
|
@ -1250,7 +1260,7 @@
|
||||||
"storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--json]"
|
"storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--json]"
|
||||||
],
|
],
|
||||||
"apple status": [
|
"apple status": [
|
||||||
"storebound apple status [--json]",
|
"storebound apple status [--platform ios|macos|tvos|visionos] [--json]",
|
||||||
"storebound apple status --cancel-submission <id> [--yes]",
|
"storebound apple status --cancel-submission <id> [--yes]",
|
||||||
"storebound apple status --wait-review [--version 1.2.0] [--timeout 48h]"
|
"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 [--since v1.1.0] [--release 1.2.0] [--from-git] [--lang en-US] [--all] [--hook \"text\"] [--dry]",
|
||||||
"storebound whats-new --languages en-US,de-DE,tr [--dir store/whats-new] [--translate \"<command>\"] [--force] [--dry] [--json]",
|
"storebound whats-new --languages en-US,de-DE,tr [--dir store/whats-new] [--translate \"<command>\"] [--force] [--dry] [--json]",
|
||||||
"storebound whats-new check [--dir store/whats-new] [--json] limits per store (Play 500, App Store 4000), untranslated files",
|
"storebound whats-new 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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
// workspace (`storebound ws status`, `storebound release status`), so all of them read an app the same way.
|
// 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).
|
// 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 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';
|
import type { RunResult } from './runner';
|
||||||
|
|
||||||
export type Exec = (argv: string[], cwd: string) => Promise<RunResult>;
|
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) : [];
|
const ios = apple?.data ? appleReleases(apple.data) : [], android = play?.data ? playReleases(play.data) : [];
|
||||||
e.release = { source: 'live', ...releaseTrains(ios, android), all: [...ios, ...android] };
|
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
|
// 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 (firstWarn.length) e.warnings = firstWarn;
|
||||||
if (e.check) for (const w of firstWarn) if (!e.check.warnings.includes(w)) e.check.warnings.push(w);
|
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);
|
e.catalog = catalogParity(summary, apple?.data ? appleProductIds(apple.data) : undefined, play?.data ? playProductIds(play.data) : undefined);
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,8 @@ export type ReleasePhase = 'live' | 'rollout' | 'halted' | 'released' | 'approve
|
||||||
|
|
||||||
export interface StoreRelease {
|
export interface StoreRelease {
|
||||||
store: 'ios' | 'android';
|
store: 'ios' | 'android';
|
||||||
|
/** App Store platform of the version (IOS, MAC_OS, ...); absent on Play. */
|
||||||
|
platform?: string;
|
||||||
version: string | null;
|
version: string | null;
|
||||||
/** iOS build number (CFBundleVersion) / highest Android versionCode. */
|
/** iOS build number (CFBundleVersion) / highest Android versionCode. */
|
||||||
build: string | null;
|
build: string | null;
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,8 @@ export function compareVersions(a: string, b: string): number {
|
||||||
return 0;
|
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. */
|
/** Version rows of GET /v1/apps/{id}/appStoreVersions. */
|
||||||
export const versionInfos = (rows: { id: string; attributes: Record<string, unknown> }[]): VersionInfo[] =>
|
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') }));
|
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
|
* 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.
|
* 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 existing = versionInfos(rows);
|
||||||
const editable = existing.find(v => EDITABLE_STATES.has(v.state));
|
const editable = existing.find(v => EDITABLE_STATES.has(v.state));
|
||||||
const others = existing.filter(v => v !== editable);
|
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));
|
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)`);
|
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 };
|
const attributes = { platform, 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 };
|
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 ----
|
// ---- translation drafts ----
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,18 @@ export function phasedFraction(p: { state?: string; currentDayNumber?: number |
|
||||||
return d ? PHASED_SCHEDULE[d - 1]! : p.state === 'INACTIVE' ? 0 : null;
|
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[] {
|
export function appleReleases(data: unknown): StoreRelease[] {
|
||||||
const versions = ((data as J)?.versions ?? []) as J[];
|
const versions = ((data as J)?.versions ?? []) as J[];
|
||||||
const recent = (((data as J)?.builds ?? []) 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
|
// 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 === 'ACTIVE') phase = 'rollout';
|
||||||
if (phase === 'live' && prState === 'PAUSED') phase = 'halted';
|
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 (prState) r.phased = { state: prState, day };
|
||||||
if (typeof v.attributes.createdDate === 'string') r.date = v.attributes.createdDate;
|
if (typeof v.attributes.createdDate === 'string') r.date = v.attributes.createdDate;
|
||||||
return r;
|
return r;
|
||||||
|
|
|
||||||
|
|
@ -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
|
* 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).
|
* 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: [],
|
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: {} } };
|
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'] : [])]);
|
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`);
|
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}';
|
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,
|
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) } } } });
|
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 } } } };
|
plan.submit = { method: 'PATCH', path: `/v1/reviewSubmissions/${sid}`, note: 'submit for App Review', body: { data: { type: 'reviewSubmissions', id: sid, attributes: { submitted: true } } } };
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,9 @@ export function rendersFindings(store: 'apple' | 'play', r: RendersCheck | undef
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppleSnapshot {
|
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). */
|
/** app.copyright from the config (undefined: not set). */
|
||||||
configCopyright?: string;
|
configCopyright?: string;
|
||||||
build: { number: string; processingState: string; usesNonExemptEncryption: boolean | null; declarationState: string | null } | null;
|
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) } : {}) }];
|
...(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[] {
|
export function appleRules(s: AppleSnapshot): Finding[] {
|
||||||
const f: 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 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 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');
|
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 (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 ${s.version.versionString} is ${s.version.state} (nothing to prepare)`);
|
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 ${s.version.versionString}: ${s.version.state}`);
|
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));
|
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)');
|
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)');
|
||||||
|
|
|
||||||
|
|
@ -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 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 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 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 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 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)]);
|
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) : [])] },
|
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,
|
{ 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).',
|
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' } },
|
props: { store: { type: 'string', enum: ['both', 'apple', 'play'], default: 'both' }, platform },
|
||||||
args: i => (i.store === 'apple' ? ['--apple'] : i.store === 'play' ? ['--play'] : []) },
|
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,
|
{ name: 'report', title: 'Shareable readiness report', command: ['report'], write: false, network: true,
|
||||||
description: 'One self-contained HTML page with the scan, check and validate results (read-only; validate only for the stores whose credentials are there, else the page says why): summary per store, findings by store and severity with guideline, evidence, fix and console link, the compliance questions only the owner can answer. Writes only .storebound/reports/readiness-<date>.html (masked: no credentials, key ids, emails or demo account) and returns its path and the summary; never opens a browser.',
|
description: 'One self-contained HTML page with the scan, check and validate results (read-only; validate only for the stores whose credentials are there, else the page says why): summary per store, findings by store and severity with guideline, evidence, fix and console link, the compliance questions only the owner can answer. Writes only .storebound/reports/readiness-<date>.html (masked: no credentials, key ids, emails or demo account) and returns its path and the summary; never opens a browser.',
|
||||||
props: { format: { type: 'string', enum: ['html', 'md'], default: 'html' }, offline: { type: 'boolean', default: false, description: 'Skip validate (no store reads)' } },
|
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,
|
{ 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.' },
|
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,
|
{ 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,
|
{ 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.' },
|
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,
|
{ 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.' },
|
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,
|
{ 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).',
|
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 },
|
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('build', i.build), ...opt('only', i.only)] },
|
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,
|
{ 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.',
|
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,
|
{ 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).' },
|
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,
|
{ 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) },
|
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,
|
{ 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.',
|
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'],
|
props: { action: { type: 'string', enum: ['on', 'pause'] }, version: { type: 'string' }, platform }, required: ['action'],
|
||||||
args: i => ['--phased', String(i.action), ...opt('version', i.version)] },
|
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,
|
{ 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.',
|
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,
|
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'] },
|
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,
|
{ 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).',
|
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)' } },
|
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), '--json', ...(i.dry_run === false ? ['--write'] : [])] },
|
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,
|
{ 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).',
|
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'] : [])] },
|
args: i => ['--json', ...(i.dry_run === false ? ['--write'] : [])] },
|
||||||
|
|
@ -365,7 +367,7 @@ export const TOOLS: ToolDef[] = [
|
||||||
args: () => ['--json'] },
|
args: () => ['--json'] },
|
||||||
{ name: 'whats_new_apply', title: 'Apply release notes per language', command: ['whats-new', 'apply'], write: true, network: false, idempotent: true,
|
{ 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.',
|
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,
|
{ 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.',
|
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)' },
|
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)' },
|
||||||
|
|
|
||||||
13
cli/serve.ts
13
cli/serve.ts
|
|
@ -145,6 +145,7 @@ export const DESKTOP_ONLY: ToolDef[] = [
|
||||||
args: i => [...opt('store', i.store), ...(i.with_automatic === true ? ['--with-automatic'] : [])] },
|
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 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 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' };
|
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. */
|
/** `--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'] : [])] },
|
...(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,
|
{ 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).',
|
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}$' } },
|
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)] : [])] },
|
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,
|
{ 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.',
|
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' } },
|
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')] })),
|
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,
|
{ 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.',
|
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' } },
|
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), ...(i.resolve_iaps === true ? ['--resolve-iaps'] : []), ...real(i, '--yes')] },
|
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,
|
{ 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.',
|
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)' } },
|
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), ...(i.resolve_iaps === true ? ['--resolve-iaps'] : []), ...real(i, '--yes')] },
|
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,
|
{ 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).',
|
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 },
|
props: { with_version: { type: 'boolean', default: false }, resolve_iaps: { type: 'boolean', default: false }, only: IDS },
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@
|
||||||
// Live apps: crash / ANR vitals (Play Reporting API) and App Store diagnostics, as in `storebound health`: over a Play
|
// 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.
|
// 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 { 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 { capabilityReport } from './apple/signing';
|
||||||
import { firstProductReport } from './apple/first-products';
|
import { firstProductReport } from './apple/first-products';
|
||||||
import { requiredDisplayTypes } from './apple/screenshots';
|
import { requiredDisplayTypes } from './apple/screenshots';
|
||||||
|
|
@ -55,8 +56,14 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
|
||||||
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
|
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
|
||||||
const appId = await c.app();
|
const appId = await c.app();
|
||||||
log('validate', 'apple: reading version, build, localizations');
|
log('validate', 'apple: reading version, build, localizations');
|
||||||
const versions = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=10`);
|
// the platform: --platform, else the one with an editable version (several: iOS, and the others are named)
|
||||||
const v: J = (await c.editableVersion()) ?? versions[0] ?? null;
|
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;
|
let build: AppleSnapshot['build'] = null;
|
||||||
const locales: AppleSnapshot['locales'] = [];
|
const locales: AppleSnapshot['locales'] = [];
|
||||||
let reviewDetail: AppleSnapshot['reviewDetail'] = null;
|
let reviewDetail: AppleSnapshot['reviewDetail'] = null;
|
||||||
|
|
@ -109,7 +116,8 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
|
||||||
subscriptions, expectedSubscriptions: wantSubs.map(({ group, sub }) => ({ id: sub.id, group: group.name,
|
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) })),
|
locales: Object.keys(sub.listings).map(id => appleLocale.get(id)).filter((x): x is string => !!x) })),
|
||||||
profile: await collectProfile(ctx.loaded),
|
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,
|
configCopyright: config.app.copyright,
|
||||||
build, locales, reviewDetail, ageRating,
|
build, locales, reviewDetail, ageRating,
|
||||||
expectedLocales: config.languages.map(l => l.apple).filter((x): x is string => !!x),
|
expectedLocales: config.languages.map(l => l.apple).filter((x): x is string => !!x),
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@
|
||||||
// STOREBOUND_TARGET_LANG (App Store locale, else Play code, else language id) in the environment; its stdout is
|
// 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.
|
// 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 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 { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import type { Language } from './lib/config';
|
import type { Language } from './lib/config';
|
||||||
|
|
@ -29,6 +30,7 @@ import {
|
||||||
resolveLanguages, setAppleWhatsNew, setPlayWhatsNew,
|
resolveLanguages, setAppleWhatsNew, setPlayWhatsNew,
|
||||||
} from './lib/whats-new';
|
} from './lib/whats-new';
|
||||||
import * as rt from './lib/rt';
|
import * as rt from './lib/rt';
|
||||||
|
import { parsePlatform } from './apple/platform';
|
||||||
|
|
||||||
const TAG = 'whats-new';
|
const TAG = 'whats-new';
|
||||||
const storeCode = (l: Language): string => l.apple ?? playCodes(l)[0] ?? l.id;
|
const storeCode = (l: Language): string => l.apple ?? playCodes(l)[0] ?? l.id;
|
||||||
|
|
@ -93,6 +95,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
||||||
const { config, root, resolve } = ctx.loaded;
|
const { config, root, resolve } = ctx.loaded;
|
||||||
const a = ctx.args;
|
const a = ctx.args;
|
||||||
if (a._[0] === 'check' || a._[0] === 'apply') {
|
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 dir = resolve(a.get('dir') ?? 'store/whats-new');
|
||||||
const files = readDir(dir, config.languages);
|
const files = readDir(dir, config.languages);
|
||||||
if (!files.length) throw new Error(`${dir}: no <language id>.md files`);
|
if (!files.length) throw new Error(`${dir}: no <language id>.md files`);
|
||||||
|
|
@ -114,7 +117,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
||||||
if (a.has('dry')) { done(TAG, 'dry run: listing files not written'); return; }
|
if (a.has('dry')) { done(TAG, 'dry run: listing files not written'); return; }
|
||||||
if (playFile && md.play !== undefined) writeFileSync(playFile, md.play);
|
if (playFile && md.play !== undefined) writeFileSync(playFile, md.play);
|
||||||
if (appleFile && md.apple !== undefined) writeFileSync(appleFile, md.apple);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const since = a.get('since') ?? (() => { try { return git(['describe', '--tags', '--abbrev=0'], root).trim(); } catch { return ''; } })();
|
const since = a.get('since') ?? (() => { try { return git(['describe', '--tags', '--abbrev=0'], root).trim(); } catch { return ''; } })();
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ presses release; you prepare, dry-run and report. `storebound mcp` deliberately
|
||||||
`storebound apple listing --version 1.3.0 --build <that number>`. Never pick "the latest build": a CI or a
|
`storebound apple listing --version 1.3.0 --build <that number>`. Never pick "the latest build": a CI or a
|
||||||
teammate may have uploaded another one meanwhile.
|
teammate may have uploaded another one meanwhile.
|
||||||
2. `storebound apple release --phased on --dry`, then without `--dry` (works while the version is being prepared).
|
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,
|
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.
|
7 timeout; `--timeout 72h`). Rejected: `review-rejections` skill.
|
||||||
4. Approved = PENDING_DEVELOPER_RELEASE. Owner releases: `storebound apple release --manual-release` (asks to confirm)
|
4. Approved = PENDING_DEVELOPER_RELEASE. Owner releases: `storebound apple release --manual-release` (asks to confirm)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
2. `storebound apple status`: the submission with UNRESOLVED_ISSUES, its items and their states.
|
||||||
3. `storebound apple resubmit --build <n>` (dry run without `--yes`): the plan table shows the build to attach, the
|
3. `storebound apple resubmit --build <n>` (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.
|
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 <n> --yes`: build n is attached by its number, missing
|
4. The owner's yes, then `storebound apple resubmit --build <n> --yes`: build n is attached by its number, missing
|
||||||
items go in, the REJECTED version item is marked resolved (`PATCH reviewSubmissionItems/{id}` `resolved: true`),
|
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
|
then it submits. Apple answers every submit with "Version is not ready to be submitted yet" (and the console's
|
||||||
|
|
|
||||||
|
|
@ -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`
|
- **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
|
(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.
|
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).
|
||||||
|
|
||||||
|
|
|
||||||
108
tests/apple-platforms.test.ts
Normal file
108
tests/apple-platforms.test.ts
Normal file
|
|
@ -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 <n> --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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -230,7 +230,7 @@ describe('apple submit --build <n> (a first submission, the desktop owner action
|
||||||
s.asc.res.get('ver-1')!.attributes.appVersionState = 'READY_FOR_SALE';
|
s.asc.res.get('ver-1')!.attributes.appVersionState = 'READY_FOR_SALE';
|
||||||
const p = project(ONE);
|
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', '--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();
|
rejected();
|
||||||
const p = project(CONFIG);
|
const p = project(CONFIG);
|
||||||
const r = await capture(() => appleStatus(p.ctx([])));
|
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).toContain('review submission rs-old (iOS) is back from App Review with UNRESOLVED_ISSUES');
|
||||||
expect(r.out).toMatch(/app version 1\.0\.0\s+REJECTED/);
|
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).toMatch(/inAppPurchaseVersion iapv-gems\s+READY_FOR_REVIEW/);
|
||||||
expect(r.out).toContain('resubmit with: storebound apple resubmit --build <n>');
|
expect(r.out).toContain('resubmit with: storebound apple resubmit --build <n>');
|
||||||
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 () => {
|
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 } });
|
s.asc.put({ type: 'reviewSubmissions', id: 'rs-empty', attributes: { platform: 'IOS', state: 'READY_FOR_REVIEW' }, rels: { app: APP } });
|
||||||
const p = project(CONFIG);
|
const p = project(CONFIG);
|
||||||
const r = await capture(() => appleStatus(p.ctx([])));
|
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'])));
|
const c = await capture(() => appleStatus(p.ctx(['--cancel-submission', 'rs-empty', '--yes'])));
|
||||||
expect([c.exit ?? 0, c.err]).toEqual([0, '']);
|
expect([c.exit ?? 0, c.err]).toEqual([0, '']);
|
||||||
expect(c.out).toContain('rs-empty is not cancellable');
|
expect(c.out).toContain('rs-empty is not cancellable');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue