apple: release type (apple.releaseType, apple release --after-approval|--manual), device-family preflight, TestFlight invite needs an installable build
This commit is contained in:
parent
4763862178
commit
85cf494c0a
24 changed files with 899 additions and 18 deletions
|
|
@ -18,6 +18,7 @@ import { type AppleSigning, validateSigning } from './signing';
|
|||
import { type AppleBuildConfig, validateAppleBuild } from './xcode';
|
||||
import { type PlayBuildConfig, validatePlayBuild } from './gradle';
|
||||
import { type OriginalityConfig, validateOriginality } from './originality';
|
||||
import { validateReleaseType } from './release-type';
|
||||
|
||||
/** consumable / non_consumable: both stores. non_renewing: Apple NON_RENEWING_SUBSCRIPTION (access for a fixed time,
|
||||
* you track the expiry); Google Play has no such type, so it becomes a one-time product your app consumes when the
|
||||
|
|
@ -678,6 +679,14 @@ export interface ShipConfig {
|
|||
screenshotTypes?: string[];
|
||||
/** The app runs on iPad: renders, checks and uploads 13" iPad screenshots (2064 x 2752) and iPad app previews. */
|
||||
ipad?: boolean;
|
||||
/** How an approved version goes live: "afterApproval" (released as soon as App Review approves it), "manual" (waits in
|
||||
* Pending Developer Release for `apple release --manual-release`) or "scheduled" (not before earliestReleaseDate).
|
||||
* Applied by `apple listing` when it creates a version and by `apple submit` before submitting (old -> new in the dry
|
||||
* run); `apple release --after-approval | --manual` changes it on an editable version. Unset: new versions are MANUAL
|
||||
* and existing ones are left as they are. */
|
||||
releaseType?: 'afterApproval' | 'manual' | 'scheduled';
|
||||
/** releaseType "scheduled": the earliest release, ISO 8601 date and time (2027-03-01T09:00:00Z). */
|
||||
earliestReleaseDate?: string;
|
||||
/**
|
||||
* Also render, check and upload a 6.3" iPhone set (Dynamic Island, medium display, 1206 x 2622 -> APP_IPHONE_61).
|
||||
* Off by default: App Store Connect lists a medium-display screenshot as required for iPhone but fills it by
|
||||
|
|
@ -1197,6 +1206,7 @@ export function validateConfig(c: unknown): string[] {
|
|||
if (an !== undefined && (typeof an !== 'string' || !an.trim())) e.push('apple.name: the App Store name (text), or leave it out to use app.name');
|
||||
else if (an !== undefined && [...an].length > APPLE_NAME_MAX) e.push(`apple.name: ${[...an].length} characters > ${APPLE_NAME_MAX} (App Store limit)`);
|
||||
if (cfg.apple.appId && !/^\d+$/.test(cfg.apple.appId)) e.push(`apple.appId: numeric Apple ID of the app (${consoleLink('asc.app-info')})`);
|
||||
e.push(...validateReleaseType(cfg.apple));
|
||||
const enc = cfg.apple.usesNonExemptEncryption;
|
||||
if (enc !== undefined && typeof enc !== 'boolean') e.push('apple.usesNonExemptEncryption: true | false');
|
||||
if (cfg.apple.screenshotTypes && !cfg.apple.screenshotTypes.every(t => /^APP_[A-Z0-9_]+$/.test(t))) e.push('apple.screenshotTypes: display types like APP_IPHONE_67');
|
||||
|
|
|
|||
111
cli/lib/device-families.ts
Normal file
111
cli/lib/device-families.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// Device families of an iOS app: what the last live version of the app on the App Store supports, against what the
|
||||
// project builds (TARGETED_DEVICE_FAMILY in the Xcode project) and what the config says (apple.ipad).
|
||||
// Apple refuses an upload that drops a device family the previous version supported: ITMS-90101 "This bundle does not
|
||||
// support one or more of the devices supported by the previous app version", after a full archive (seen live: an app
|
||||
// whose live version ran on iPad, built with TARGETED_DEVICE_FAMILY = 1). So `validate` and `apple build` compare first.
|
||||
// Pure helpers; the store reads are in cli/apple/device-families.ts.
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export interface Families { iphone: boolean; ipad: boolean }
|
||||
|
||||
/** Families in TARGETED_DEVICE_FAMILY: 1 iPhone/iPod, 2 iPad (3 tvOS, 4 watchOS, 6 Mac Catalyst, 7 Vision). Pure. */
|
||||
export function familiesOfList(list: number[]): Families { return { iphone: list.includes(1), ipad: list.includes(2) }; }
|
||||
|
||||
/** The families a store signal names: build bundle deviceProtocols, file-size device models, screenshot display types. Pure. */
|
||||
export function familiesOfStrings(values: string[]): Families {
|
||||
const t = values.join(' ');
|
||||
return { iphone: /iphone|ipod/i.test(t), ipad: /ipad/i.test(t) };
|
||||
}
|
||||
|
||||
export interface LiveFamilies { version: string; state: string; families: Families; evidence: string[] }
|
||||
|
||||
/** Merges the signals of one live version: any signal naming iPad means the version supports iPad. null when no signal
|
||||
* names any family (unknown, never "iPhone only"). Pure. */
|
||||
export function liveFamilies(version: string, state: string, signals: { name: string; values: string[] }[]): LiveFamilies | null {
|
||||
const evidence: string[] = [];
|
||||
const families: Families = { iphone: false, ipad: false };
|
||||
for (const s of signals) {
|
||||
const f = familiesOfStrings(s.values);
|
||||
if (!f.iphone && !f.ipad) continue;
|
||||
families.iphone ||= f.iphone;
|
||||
families.ipad ||= f.ipad;
|
||||
evidence.push(`${s.name}: ${[f.iphone && 'iPhone', f.ipad && 'iPad'].filter(Boolean).join(' + ')}`);
|
||||
}
|
||||
return evidence.length ? { version, state, families, evidence } : null;
|
||||
}
|
||||
|
||||
export interface ProjectFamilies { file: string; list: number[]; families: Families; configurations: string[] }
|
||||
|
||||
/** The Release build configurations of a pbxproj with their TARGETED_DEVICE_FAMILY: the ones of the app's bundle id,
|
||||
* else every Release configuration that is not an extension (bundle id below the app's). Pure. */
|
||||
export function pbxFamilies(text: string, bundleId: string, file = 'project.pbxproj'): ProjectFamilies | null {
|
||||
const blocks: { name: string; bundle: string | null; family: string | null; ext: boolean }[] = [];
|
||||
const re = /isa = XCBuildConfiguration;[^{]*?buildSettings = \{([\s\S]*?)\};\s*name = ("[^"]*"|[\w.-]+);/g;
|
||||
for (let m: RegExpExecArray | null; (m = re.exec(text));) {
|
||||
const body = m[1]!;
|
||||
const fam = /TARGETED_DEVICE_FAMILY = ("?)([\d, ]+)\1;/.exec(body)?.[2] ?? null;
|
||||
const bid = /PRODUCT_BUNDLE_IDENTIFIER = "?([^";\s]+)"?;/.exec(body)?.[1] ?? null;
|
||||
blocks.push({ name: m[2]!.replace(/"/g, ''), bundle: bid, family: fam, ext: /WRAPPER_EXTENSION = appex|APPLICATION_EXTENSION_API_ONLY = YES/.test(body) || (bid !== null && bid.startsWith(`${bundleId}.`)) });
|
||||
}
|
||||
const release = blocks.filter(b => b.name === 'Release' && b.family !== null);
|
||||
let pick = release.filter(b => b.bundle === bundleId);
|
||||
if (!pick.length) pick = release.filter(b => !b.ext);
|
||||
if (!pick.length) return null;
|
||||
const list = [...new Set(pick.flatMap(b => b.family!.split(',').map(x => Number(x.trim())).filter(n => Number.isFinite(n) && n > 0)))].sort((a, b) => a - b);
|
||||
return { file, list, families: familiesOfList(list), configurations: pick.map(b => b.family!.replace(/\s/g, '')) };
|
||||
}
|
||||
|
||||
const SKIP = new Set(['node_modules', 'Pods', '.git', 'build', 'DerivedData', 'Carthage', '.build', 'dist', 'vendor']);
|
||||
|
||||
/** Every project.pbxproj under root (depth <= 5), root-relative with forward slashes. */
|
||||
export function findPbxprojs(root: string, depth = 5): string[] {
|
||||
const out: string[] = [];
|
||||
const walk = (dir: string, rel: string, d: number): void => {
|
||||
if (d > depth || !existsSync(dir)) return;
|
||||
let names: import('node:fs').Dirent[];
|
||||
try { names = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
||||
for (const e of names) {
|
||||
if (!e.isDirectory() || SKIP.has(e.name)) continue;
|
||||
const r = rel ? `${rel}/${e.name}` : e.name;
|
||||
if (e.name.endsWith('.xcodeproj')) { if (existsSync(join(dir, e.name, 'project.pbxproj'))) out.push(`${r}/project.pbxproj`); continue; }
|
||||
walk(join(dir, e.name), r, d + 1);
|
||||
}
|
||||
};
|
||||
walk(root, '', 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The project's device families, read from the Xcode project (apple.build.project when set, else the first project
|
||||
* that has the app's configurations). null when the project is not on this machine or sets no families. */
|
||||
export function projectFamilies(root: string, bundleId: string, project?: string): ProjectFamilies | null {
|
||||
const files = project && project.endsWith('.xcodeproj') ? [`${project.replace(/\\/g, '/')}/project.pbxproj`] : findPbxprojs(root);
|
||||
for (const f of files) {
|
||||
try {
|
||||
const r = pbxFamilies(readFileSync(join(root, f), 'utf8'), bundleId, f);
|
||||
if (r) return r;
|
||||
} catch { /* unreadable: next */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface FamilyVerdict { level: 'ok' | 'blocker' | 'warn'; msg: string; fix?: string }
|
||||
|
||||
/** Compares the live version, the project and the config. Blocker: the live version supports iPad and the project does
|
||||
* not (the upload is refused with ITMS-90101) or apple.ipad is not true (iPad screenshots and checks are off). Pure. */
|
||||
export function familyVerdicts(i: { live: LiveFamilies | null; project: ProjectFamilies | null; ipad: boolean | undefined }): FamilyVerdict[] {
|
||||
const out: FamilyVerdict[] = [];
|
||||
const { live, project } = i;
|
||||
const fixProject = 'set TARGETED_DEVICE_FAMILY to "1,2" (Xcode: target > General > Supported Destinations: add iPad)';
|
||||
if (!live) {
|
||||
out.push({ level: 'warn', msg: `device families of the live version not known${project ? '' : ' and no Xcode project found'}: if it supports iPad, an iPhone-only build is refused at upload (ITMS-90101)`, fix: 'storebound validate (reads the live version from App Store Connect)' });
|
||||
return out;
|
||||
}
|
||||
const liveText = `${[live.families.iphone && 'iPhone', live.families.ipad && 'iPad'].filter(Boolean).join(' + ')} (version ${live.version} ${live.state}; ${live.evidence.join('; ')})`;
|
||||
if (live.families.ipad) {
|
||||
if (project && !project.families.ipad) out.push({ level: 'blocker', msg: `the live version supports ${liveText}, but ${project.file} builds TARGETED_DEVICE_FAMILY = ${project.configurations.join(' / ')} (no iPad): Apple refuses the upload after the archive with ITMS-90101`, fix: `${fixProject}; and apple.ipad: true in the config` });
|
||||
if (i.ipad !== true) out.push({ level: 'blocker', msg: `the live version supports ${liveText}, but apple.ipad is not true in the config`, fix: 'set apple.ipad: true (iPad screenshots are then required) and TARGETED_DEVICE_FAMILY "1,2"' });
|
||||
if (!out.length) out.push({ level: 'ok', msg: `device families: live ${liveText}; project ${project ? `TARGETED_DEVICE_FAMILY ${project.configurations.join(' / ')}` : 'not read'}; apple.ipad true` });
|
||||
} else out.push({ level: 'ok', msg: `device families: live ${liveText}; nothing to keep` });
|
||||
return out;
|
||||
}
|
||||
|
|
@ -69,7 +69,7 @@ export const versionInfos = (rows: { id: string; attributes: Record<string, unkn
|
|||
* string changes only while PREPARE_FOR_SUBMISSION), create a new one, or refuse (the number exists, is not higher than
|
||||
* a version that went through review, or another version is in review / pending release). Pure.
|
||||
*/
|
||||
export function planVersion(rows: { id: string; attributes: Record<string, unknown> }[], want: string, attrs: { copyright: string; releaseType: string }, platform = 'IOS'): VersionPlan {
|
||||
export function planVersion(rows: { id: string; attributes: Record<string, unknown> }[], want: string, attrs: { copyright: string; releaseType?: string; earliestReleaseDate?: string }, platform = 'IOS'): VersionPlan {
|
||||
const existing = versionInfos(rows);
|
||||
const editable = existing.find(v => EDITABLE_STATES.has(v.state));
|
||||
const others = existing.filter(v => v !== editable);
|
||||
|
|
@ -87,7 +87,8 @@ export function planVersion(rows: { id: string; attributes: Record<string, unkno
|
|||
return refuse(`version ${want} is not higher than ${higher.versionString} (${higher.state}); App Store versions only go up`);
|
||||
if (editable) {
|
||||
const cur = rows.find(r => r.id === editable.id)!.attributes;
|
||||
const wantAttrs = { versionString: want, copyright: attrs.copyright, releaseType: attrs.releaseType };
|
||||
// releaseType only when asked for (apple.releaseType): an existing version keeps the one it has
|
||||
const wantAttrs: Record<string, string | undefined> = { versionString: want, copyright: attrs.copyright, releaseType: attrs.releaseType, earliestReleaseDate: attrs.releaseType === 'SCHEDULED' ? attrs.earliestReleaseDate : undefined };
|
||||
const p = planItem(editable.id, { id: editable.id, attributes: cur }, wantAttrs);
|
||||
if (editable.versionString !== want && editable.state !== 'PREPARE_FOR_SUBMISSION')
|
||||
return refuse(`the editable version ${editable.versionString} is ${editable.state}; its version string changes only while PREPARE_FOR_SUBMISSION (use --version ${editable.versionString}, or change it in App Store Connect)`);
|
||||
|
|
@ -102,7 +103,8 @@ export function planVersion(rows: { id: string; attributes: Record<string, unkno
|
|||
if (busy) return refuse(`version ${busy.versionString} is ${busy.state}; App Store Connect allows one version in progress (${busy.state === 'PENDING_DEVELOPER_RELEASE'
|
||||
? `release it first: storebound apple release --manual-release --version ${busy.versionString} --dry, then without --dry`
|
||||
: busy.state === 'WAITING_FOR_REVIEW' || busy.state === 'IN_REVIEW' ? 'wait for the review, or remove it from review in App Store Connect' : 'wait for its release or remove it from review'})`);
|
||||
const attributes = { platform, versionString: want, copyright: attrs.copyright, releaseType: attrs.releaseType };
|
||||
const attributes: Record<string, string> = { platform, versionString: want, copyright: attrs.copyright, releaseType: attrs.releaseType ?? 'MANUAL' };
|
||||
if (attributes.releaseType === 'SCHEDULED' && attrs.earliestReleaseDate) attributes.earliestReleaseDate = attrs.earliestReleaseDate;
|
||||
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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
54
cli/lib/release-type.ts
Normal file
54
cli/lib/release-type.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// How an App Store version goes live after App Review: apple.releaseType in the config, appStoreVersions.releaseType
|
||||
// on the store. MANUAL leaves an approved version in PENDING_DEVELOPER_RELEASE until someone releases it; AFTER_APPROVAL
|
||||
// releases it as soon as it is approved; SCHEDULED releases it at earliestReleaseDate at the earliest (Apple:
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/appstoreversion/attributes, releaseType, earliestReleaseDate).
|
||||
// Pure: the commands (apple listing, apple submit, apple release) read the version and send the PATCH.
|
||||
import type { FieldDiff } from './listing-plan';
|
||||
|
||||
export const RELEASE_TYPES = ['afterApproval', 'manual', 'scheduled'] as const;
|
||||
export type ReleaseTypeName = (typeof RELEASE_TYPES)[number];
|
||||
export type AscReleaseType = 'AFTER_APPROVAL' | 'MANUAL' | 'SCHEDULED';
|
||||
|
||||
export const ASC_RELEASE_TYPE: Record<ReleaseTypeName, AscReleaseType> = { afterApproval: 'AFTER_APPROVAL', manual: 'MANUAL', scheduled: 'SCHEDULED' };
|
||||
|
||||
/** Version states in which App Store Connect still takes a release type change: everything before approval.
|
||||
* Once the version is approved (PENDING_DEVELOPER_RELEASE and later) it is released with `apple release --manual-release`. */
|
||||
export const RELEASE_TYPE_EDITABLE = new Set(['PREPARE_FOR_SUBMISSION', 'DEVELOPER_REJECTED', 'REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY',
|
||||
'READY_FOR_REVIEW', 'WAITING_FOR_REVIEW', 'IN_REVIEW']);
|
||||
|
||||
export interface WantedRelease { releaseType: AscReleaseType; earliestReleaseDate?: string }
|
||||
|
||||
/** The release type apple.releaseType asks for, or null when the config does not say (then nothing is touched). */
|
||||
export function wantedRelease(apple: { releaseType?: ReleaseTypeName; earliestReleaseDate?: string } | undefined): WantedRelease | null {
|
||||
if (!apple?.releaseType) return null;
|
||||
const releaseType = ASC_RELEASE_TYPE[apple.releaseType];
|
||||
return releaseType === 'SCHEDULED' && apple.earliestReleaseDate ? { releaseType, earliestReleaseDate: apple.earliestReleaseDate } : { releaseType };
|
||||
}
|
||||
|
||||
/** Problems with apple.releaseType / apple.earliestReleaseDate. Pure. */
|
||||
export function validateReleaseType(a: { releaseType?: unknown; earliestReleaseDate?: unknown } | undefined): string[] {
|
||||
if (!a) return [];
|
||||
const e: string[] = [];
|
||||
if (a.releaseType !== undefined && !(RELEASE_TYPES as readonly unknown[]).includes(a.releaseType)) e.push(`apple.releaseType: ${RELEASE_TYPES.join(' | ')} (afterApproval releases an approved version at once; manual waits for \`apple release --manual-release\`)`);
|
||||
if (a.earliestReleaseDate !== undefined) {
|
||||
if (typeof a.earliestReleaseDate !== 'string' || Number.isNaN(Date.parse(a.earliestReleaseDate))) e.push('apple.earliestReleaseDate: an ISO 8601 date and time, e.g. 2027-03-01T09:00:00Z');
|
||||
else if (a.releaseType !== 'scheduled') e.push('apple.earliestReleaseDate: only with apple.releaseType "scheduled"');
|
||||
}
|
||||
if (a.releaseType === 'scheduled' && a.earliestReleaseDate === undefined) e.push('apple.releaseType "scheduled": apple.earliestReleaseDate is required (ISO 8601 date and time)');
|
||||
return e;
|
||||
}
|
||||
|
||||
/** The attribute changes that bring a version to `want` (old -> new), [] when it is there already. A scheduled date is
|
||||
* compared as an instant. Pure. */
|
||||
export function releaseTypeChanges(cur: Record<string, unknown>, want: WantedRelease): FieldDiff[] {
|
||||
const out: FieldDiff[] = [];
|
||||
const have = cur.releaseType == null ? null : String(cur.releaseType);
|
||||
if (have !== want.releaseType) out.push({ field: 'releaseType', from: have, to: want.releaseType });
|
||||
if (want.releaseType === 'SCHEDULED' && want.earliestReleaseDate) {
|
||||
const had = cur.earliestReleaseDate == null ? null : String(cur.earliestReleaseDate);
|
||||
if (had === null || Date.parse(had) !== Date.parse(want.earliestReleaseDate)) out.push({ field: 'earliestReleaseDate', from: had, to: want.earliestReleaseDate });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const changeText = (c: FieldDiff[]): string => c.map(x => `${x.field} ${x.from ?? '(none)'} -> ${x.to}`).join(', ');
|
||||
|
|
@ -320,3 +320,22 @@ export function betaDescriptions(t: TfConfig | undefined, langs: { id: string; a
|
|||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- invitations need an installable build ----
|
||||
|
||||
/** Whether a build can be installed by the testers of a group: valid, not expired, and past Beta App Review for an
|
||||
* external group (externalBuildState BETA_APPROVED / IN_BETA_TESTING), ready for internal testing for an internal one
|
||||
* (internalBuildState READY_FOR_BETA_TESTING / IN_BETA_TESTING). `detail`: the build's buildBetaDetails attributes. Pure. */
|
||||
export function installableBuild(build: { attributes: Record<string, unknown> }, detail: Record<string, unknown> | null | undefined, internal: boolean): boolean {
|
||||
if (build.attributes.expired === true) return false;
|
||||
if (build.attributes.processingState !== undefined && build.attributes.processingState !== 'VALID') return false;
|
||||
const state = String((internal ? detail?.internalBuildState : detail?.externalBuildState) ?? '');
|
||||
return internal ? ['READY_FOR_BETA_TESTING', 'IN_BETA_TESTING'].includes(state) : ['BETA_APPROVED', 'IN_BETA_TESTING'].includes(state);
|
||||
}
|
||||
|
||||
/** The line for testers that are added but cannot be invited yet. Pure. */
|
||||
export const noInstallableBuildHint = (groups: string[]): string =>
|
||||
`tester added; the invite goes out when a build in ${groups.join(', ')} passes Beta App Review (re-run with --invite then)`;
|
||||
|
||||
/** Apple's 409 STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS on POST /v1/betaTesterInvitations (seen live). Pure. */
|
||||
export const noInstallableBuilds409 = (e: unknown): boolean => /NO_INSTALLABLE_BUILDS/.test(String((e as Error)?.message ?? e));
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ export interface AppleSnapshot {
|
|||
/** Screenshots of the version that are not COMPLETE (cli/apple/screenshot-health.ts): a submit answers 409
|
||||
* ASSET_IN_POST_PROCESSING while any is left. */
|
||||
screenshotsPending?: Pending[];
|
||||
/** Live version's device families vs the Xcode project and apple.ipad (cli/lib/device-families.ts familyVerdicts); undefined: not iOS. */
|
||||
deviceFamilies?: { level: 'ok' | 'blocker' | 'warn'; msg: string; fix?: string }[];
|
||||
/** 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). */
|
||||
|
|
@ -215,6 +217,7 @@ export function appleRules(s: AppleSnapshot): Finding[] {
|
|||
else if (EDITABLE.includes(s.version.state)) add('ok', 'version', `version ${vl}: ${s.version.state}`);
|
||||
else if (IN_FLIGHT.includes(s.version.state)) add('warn', 'version', `version ${vl} is ${s.version.state} (nothing to prepare)`);
|
||||
else add('warn', 'version', `version ${vl}: ${s.version.state}`);
|
||||
for (const d of s.deviceFamilies ?? []) add(d.level, 'device-families', d.msg, d.fix);
|
||||
{
|
||||
const pend = s.screenshotsPending ?? [];
|
||||
const lines = describePending(pend);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue