apple: release type (apple.releaseType, apple release --after-approval|--manual), device-family preflight, TestFlight invite needs an installable build

This commit is contained in:
CRTkafa 2026-10-27 17:07:37 +07:00
parent 4763862178
commit 85cf494c0a
24 changed files with 899 additions and 18 deletions

View file

@ -36,6 +36,8 @@ import { pump } from '../lib/runner';
import { parseMobileprovision, toXmlPlist } from '../lib/plist';
import { nextBuildNumber } from '../lib/release';
import { exportOptions, profileDirs } from '../lib/signing';
import { type FamilyVerdict, familyVerdicts, projectFamilies } from '../lib/device-families';
import { liveDeviceFamilies } from './device-families';
import { keychainPassword, keychainSigningArgs, parseKeychainList, showSecurity, sshBuildRefusal, unlockSteps } from '../lib/keychain';
import { appGroupSteps, appGroupVerdicts, type GroupEvidence } from '../lib/targets';
import { capacitorProject, webStep } from '../lib/web-assets';
@ -186,6 +188,18 @@ export async function run(ctx: Ctx): Promise<void> {
if (build && !/^\d+(\.\d+){0,2}$/.test(build)) throw new Error('--build-number: digits, e.g. 42 or 1.0.42');
const buildNo = build ?? '<next build number>';
// device families: an upload that drops one the live version supports (iPad) is refused only AFTER the archive (ITMS-90101)
// so compare first: stop before building with the exact fix (--allow-device-gap builds anyway)
let deviceFamilies: FamilyVerdict[] = [];
try {
const c = await ascClient(root, bundleId, config.apple?.appId);
deviceFamilies = familyVerdicts({ live: await liveDeviceFamilies(c, await c.app()), project: projectFamilies(root, bundleId, target.project), ipad: config.apple?.ipad });
} catch (e) { warn(TAG, `device families of the live version not compared (${(e as Error).message.split('\n')[0]?.slice(0, 120)})`); }
const familyBlockers = deviceFamilies.filter(d => d.level === 'blocker');
for (const d of deviceFamilies) if (d.level === 'warn' || (d.level === 'blocker' && (dry || a.has('allow-device-gap')))) warn(TAG, `${d.msg}${d.fix ? ` -> ${d.fix}` : ''}`);
if (familyBlockers.length && !dry && !a.has('allow-device-gap'))
throw new Error(`stopped before the archive: ${familyBlockers.map(d => `${d.msg}. Fix: ${d.fix}`).join('\n')}\n(--allow-device-gap builds anyway)`);
const scan = discoverTargets(root, config);
const ids = [bundleId, ...scan.extensions.map(t => t.bundleId)];
const profiles: Record<string, string> = {};
@ -232,7 +246,7 @@ export async function run(ctx: Ctx): Promise<void> {
];
const unlockPreview = unlock ? unlockSteps(keychain ?? '<login keychain>', []).map(showSecurity) : [];
const plan = { framework: target.framework, workspace: target.workspace ?? null, project: target.project ?? null, scheme: target.scheme, configuration,
version: version ?? '(project value)', build: buildNo, derivedData: derivedData ?? '(Xcode default)', pathFree, teamId, signingStyle: style, exportOptions: opts, out: relative(root, out) || '.', steps: [...unlockPreview, ...steps], notes: target.notes };
version: version ?? '(project value)', build: buildNo, deviceFamilies, derivedData: derivedData ?? '(Xcode default)', pathFree, teamId, signingStyle: style, exportOptions: opts, out: relative(root, out) || '.', steps: [...unlockPreview, ...steps], notes: target.notes };
const webOpts = { config, root, cap, store: 'apple' as const, tag: TAG, noPrebuild: a.has('no-prebuild'), allowStale: a.has('allow-stale-web') };
if (dry) {
const web = await webStep({ ...webOpts, dry: true, quiet: json });

View file

@ -0,0 +1,41 @@
// The device families of the app's last live iOS version, read from App Store Connect (cli/lib/device-families.ts compares
// them with the project and the config). Signals, merged because none is documented as THE answer:
// - the live version's build bundle: buildBundles.deviceProtocols and the device models of buildBundleFileSizes
// - the screenshot sets of the live version: an APP_IPAD_* display type means the version was submitted for iPad
// Live version = READY_FOR_SALE / READY_FOR_DISTRIBUTION; else the newest one that was on sale (REPLACED_WITH_NEW_VERSION).
import { compareVersions } from '../lib/listing-plan';
import { liveFamilies, type LiveFamilies } from '../lib/device-families';
import type { Asc, J } from './asc';
import { type AscPlatform, versionsPath } from './platform';
const ON_SALE = ['READY_FOR_SALE', 'READY_FOR_DISTRIBUTION'];
const WAS_ON_SALE = [...ON_SALE, 'REPLACED_WITH_NEW_VERSION'];
/** The live (or last live) version among the rows of GET appStoreVersions, newest first. Pure. */
export function lastLiveVersion(rows: J[]): J | null {
const state = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState;
const pick = (states: string[]): J | null => rows.filter(v => states.includes(state(v)))
.sort((a, b) => compareVersions(String(b.attributes.versionString), String(a.attributes.versionString)))[0] ?? null;
return pick(ON_SALE) ?? pick(WAS_ON_SALE);
}
/** Reads the families of the last live version of the platform (iOS unless named); null when there is none or no signal names a family. */
export async function liveDeviceFamilies(c: Asc, appId: string, platform: AscPlatform = 'IOS'): Promise<LiveFamilies | null> {
const v = lastLiveVersion(await c.all(versionsPath(appId, platform, 20)));
if (!v) return null;
const signals: { name: string; values: string[] }[] = [];
const b = (await c.maybe(`/v1/appStoreVersions/${v.id}/build`))?.data;
if (b) {
const full = await c.maybe(`/v1/builds/${b.id}?include=buildBundles`);
const bundles = (full?.included ?? []).filter((x: J) => x.type === 'buildBundles' && (x.attributes?.bundleType ?? 'APP') === 'APP');
signals.push({ name: `build ${b.attributes?.version ?? b.id} deviceProtocols`, values: bundles.flatMap((x: J) => (x.attributes?.deviceProtocols ?? []) as string[]) });
const models: string[] = [];
for (const x of bundles) for (const f of await c.all(`/v1/buildBundles/${x.id}/buildBundleFileSizes?limit=200`)) models.push(String(f.attributes?.deviceModel ?? ''));
signals.push({ name: `build ${b.attributes?.version ?? b.id} device models`, values: models });
}
const types: string[] = [];
for (const l of (await c.all(`/v1/appStoreVersions/${v.id}/appStoreVersionLocalizations?limit=50`)).slice(0, 5))
for (const s of await c.all(`/v1/appStoreVersionLocalizations/${l.id}/appScreenshotSets?limit=50`)) types.push(String(s.attributes?.screenshotDisplayType ?? ''));
signals.push({ name: 'screenshot sets', values: types });
return liveFamilies(String(v.attributes.versionString), String(v.attributes.appVersionState ?? v.attributes.appStoreState), signals);
}

View file

@ -5,7 +5,7 @@
// 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
// status=translate (listing translate) are skipped unless --include-drafts.
// - the editable iOS version: versionString, copyright, release type MANUAL; with --build that processed build is attached
// - the editable iOS version: versionString, copyright, release type (apple.releaseType, MANUAL when new and unset); with --build that processed build is attached
// - appInfoLocalizations: name, subtitle, privacy policy URL per locale
// - appStoreVersionLocalizations: description, keywords, promotional text, what's new (not on a first version),
// support + marketing URL per locale
@ -21,6 +21,7 @@ import { mergeAppleListing, overLimits } from '../lib/listing-pull';
import { counts, draftReason, draftSections, type ItemPlan, itemLines, planItem, planLocales, planVersion, short, type Skipped, type VersionPlan } from '../lib/listing-plan';
import { createOnce, findOrCreate, getById } from '../lib/upsert';
import { appleName } from '../lib/config';
import { wantedRelease } from '../lib/release-type';
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { done, log, printJson, warn } from '../lib/log';
@ -117,7 +118,8 @@ async function planListing(ctx: Ctx, c: Asc, appId: string, version: string, pla
}
const copyright = app.copyright ?? `${new Date().getFullYear()} ${app.name}`;
const rows = await c.all(versionsPath(appId, platform));
const versionPlan = planVersion(rows, version, { copyright, releaseType: 'MANUAL' }, platform);
const rel = wantedRelease(config.apple);
const versionPlan = planVersion(rows, version, { copyright, releaseType: rel?.releaseType, earliestReleaseDate: rel?.earliestReleaseDate }, platform);
let build: AppleListingPlan['build'] = null;
const number = ctx.args.get('build');

View file

@ -1,10 +1,17 @@
// Release management for an App Store version.
// storebound apple release --phased on|pause|resume|complete [--version 1.2.0] [--dry] [--yes]
// storebound apple release --manual-release [--version 1.2.0] [--first-release] [--yes] [--json]
// storebound apple release --after-approval | --manual | --scheduled <ISO date> [--version 1.2.0] [--first-release] [--yes] [--json]
// Both take --platform ios|macos|tvos|visionos; without it the platform that has the version this needs is used (both: asks).
// Phased release: 7-day automatic rollout to users with automatic updates (1, 2, 5, 10, 20, 50, 100 %); anyone can
// still update by hand from the App Store. Pause holds it (up to 30 days in total), it is NOT a rollback: users who
// updated keep the build. "complete" releases to everyone at once.
// --after-approval / --manual / --scheduled set how the version goes live (appStoreVersions.releaseType; the config's
// apple.releaseType does the same in `apple listing` and `apple submit`). Allowed until App Review approves the version
// (Prepare for Submission, rejected, Waiting for Review, In Review); an approved one (Pending Developer Release) is
// released with --manual-release, a released one is final. Setting it when it is that type already is exit 7 under the
// ship-step contract. An update of an app on sale is an agent step; --after-approval on the first version of an app
// puts it on sale by itself, so that one is the owner's (exit 3 OWNER_STEP unless --first-release).
// --manual-release releases an approved version (PENDING_DEVELOPER_RELEASE). Public and irreversible: asks first
// (--yes in scripts). Releasing an UPDATE of an app already on sale is an agent step (no owner gate, also in a
// pipeline; under the ship-step contract: exit 7 when the version is released already). The first-ever release of an
@ -18,6 +25,7 @@ import { consoleUrl } from '../lib/console-links';
import { appleReleaseKind, enforceFirstRelease } from '../lib/release-kind';
import { type PhasedAction, phasedRequest } from '../lib/release';
import { createOnce } from '../lib/upsert';
import { ASC_RELEASE_TYPE, changeText, RELEASE_TYPE_EDITABLE, releaseTypeChanges, type WantedRelease } from '../lib/release-type';
import { ascClient, type J } from './asc';
import { allVersions, parsePlatform, pickPlatform, platformLabel, versionsPath } from './platform';
@ -26,12 +34,16 @@ export async function run(ctx: Ctx): Promise<void> {
const a = ctx.args;
const phased = a.get('phased') as PhasedAction | undefined;
const manual = a.has('manual-release');
if (!phased && !manual) throw new Error('usage: apple release --phased on|pause|resume|complete | --manual-release [--version x.y.z] [--dry] [--yes]');
const typeFlags = [a.has('after-approval') && 'afterApproval', a.has('manual') && 'manual', a.has('scheduled') && 'scheduled'].filter((x): x is 'afterApproval' | 'manual' | 'scheduled' => !!x);
if (typeFlags.length > 1) throw new Error('--after-approval, --manual and --scheduled are alternatives: give one');
if (!phased && !manual && !typeFlags.length) throw new Error('usage: apple release --phased on|pause|resume|complete | --manual-release | --after-approval | --manual | --scheduled <ISO date> [--version x.y.z] [--dry] [--yes]');
if (typeFlags.length && (phased || manual)) throw new Error('the release type flags (--after-approval, --manual, --scheduled) are a command of their own: run --phased / --manual-release separately');
if (phased && !['on', 'pause', 'resume', 'complete'].includes(phased)) throw new Error('--phased: on | pause | resume | complete');
const dry = a.has('dry');
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
const appId = await c.app();
const want = a.get('version');
if (typeFlags.length) return setReleaseType(ctx, c, appId, typeFlags[0]!);
const state = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState;
shipReport({ version: want, url: consoleUrl('asc.version') });
// the ship-step contract: the version is released already (and none waits for release): nothing to do, exit 7
@ -96,3 +108,52 @@ export async function run(ctx: Ctx): Promise<void> {
}
done('apple', `${tag}: ${req.note}`);
}
/** apple release --after-approval | --manual | --scheduled: PATCH appStoreVersions releaseType on a version App Review has not approved yet. */
async function setReleaseType(ctx: Ctx, c: Awaited<ReturnType<typeof ascClient>>, appId: string, name: 'afterApproval' | 'manual' | 'scheduled'): Promise<void> {
const a = ctx.args;
const TAG = 'apple';
const want = a.get('version');
const state = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState;
const date = name === 'scheduled' ? a.required('scheduled', 'ISO date and time', 'e.g. --scheduled 2027-03-01T09:00:00Z') : undefined;
if (date && Number.isNaN(Date.parse(date))) throw new Error(`--scheduled ${date}: an ISO 8601 date and time, e.g. 2027-03-01T09:00:00Z`);
const target: WantedRelease = { releaseType: ASC_RELEASE_TYPE[name], ...(date ? { earliestReleaseDate: date } : {}) };
shipReport({ version: want, url: consoleUrl('asc.version') });
const fits = (x: J): boolean => (!want || x.attributes.versionString === want) && (RELEASE_TYPE_EDITABLE.has(state(x)) || state(x) === 'PENDING_DEVELOPER_RELEASE');
const platform = parsePlatform(a.get('platform')) ?? pickPlatform(await allVersions(c, appId), want ? [fits, (x: J) => x.attributes.versionString === want] : fits, undefined, 'a version that is not approved yet');
const versions = await c.all(versionsPath(appId, platform, 20));
const v: J | undefined = want ? versions.find(x => x.attributes.versionString === want) : versions.find(x => RELEASE_TYPE_EDITABLE.has(state(x))) ?? versions.find(x => state(x) === 'PENDING_DEVELOPER_RELEASE');
if (!v) throw new Error(`${platformLabel(platform)}: ${want ? `no version ${want}` : 'no version waiting for App Review (Prepare for Submission, rejected, Waiting for Review, In Review)'}`);
const versionString = String(v.attributes.versionString);
const tag = `${versionString} ${platformLabel(platform)} (${state(v)})`;
const changes = releaseTypeChanges(v.attributes, target);
const kind = appleReleaseKind(versions.map((x: J) => ({ versionString: String(x.attributes.versionString), state: state(x) })));
shipReport({ version: versionString, state: state(v) });
const result = (extra: Record<string, unknown>): void => { if (a.has('json')) printJson({ version: versionString, state: state(v), platform, releaseType: target.releaseType, from: v.attributes.releaseType ?? null, releaseKind: kind.kind, ...extra }); };
if (!changes.length) {
shipNoop({ version: versionString, state: state(v) });
result({ changed: false, alreadyThere: true });
done(TAG, `${tag}: release type is ${target.releaseType} already: nothing changed`);
return;
}
if (!RELEASE_TYPE_EDITABLE.has(state(v))) {
throw new Error(`${tag}: the release type can no longer be changed${state(v) === 'PENDING_DEVELOPER_RELEASE' ? ` (App Review approved it and it waits for you: release it with storebound apple release --manual-release --version ${versionString})` : ' (it is approved or released; App Store Connect only takes the change before approval)'}`);
}
if (a.has('dry')) {
log(TAG, `would set the release type of ${tag}: ${changeText(changes)} (dry)`);
result({ changed: false, dryRun: true, changes });
return;
}
// after approval = on sale at once: for the first version of an app that is the owner's step
if (target.releaseType === 'AFTER_APPROVAL' && !enforceFirstRelease(ctx, kind, { store: 'apple', what: 'the first release of the app (it goes on sale by itself once approved)', consoleUrl: consoleUrl('asc.version') })) return;
if (!(await confirm(ctx, `Set the release type of ${tag}: ${changeText(changes)}?`))) return;
try {
await c.asc('PATCH', `/v1/appStoreVersions/${v.id}`, { data: { type: 'appStoreVersions', id: v.id, attributes: Object.fromEntries(changes.map(ch => [ch.field, ch.to])) } });
} catch (e) {
if (/-> 409/.test((e as Error).message)) throw new Error(`${tag}: App Store Connect refuses the release type change now (409): ${(e as Error).message}
It is editable until App Review approves the version; after that use apple release --manual-release.`);
throw e;
}
result({ changed: true, changes });
done(TAG, `${tag}: release type ${changeText(changes)}`);
}

View file

@ -21,6 +21,7 @@
// is --version, else the one an open submission holds, else the editable one (Prepare for Submission or rejected).
// Under `serve` (the desktop's owner action) the plan table is printed next to the --json result.
import { sellsOn } from '../lib/config';
import { changeText, RELEASE_TYPE_EDITABLE, releaseTypeChanges, wantedRelease } from '../lib/release-type';
import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx';
import { done, log, printJson, warn } from '../lib/log';
@ -97,6 +98,10 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
if (mode === 'submit' && !SUBMITTABLE_APP.has(stateOf(v)) && !held.has(v.id)) throw new Error(`App Store version ${String(v.attributes.versionString)} (${pl}) is ${stateOf(v)}: only a version in Prepare for Submission (or a rejected one) can be submitted`);
const versionString = String(v.attributes.versionString);
if (!a.has('json')) log(TAG, `app version ${versionString} (${pl}): ${stateOf(v)}`);
// apple.releaseType: the version goes live as the config says (afterApproval | manual | scheduled); set before the submit when it differs
const relWant = wantedRelease(config.apple);
const relChanges = relWant ? releaseTypeChanges(v.attributes, relWant) : [];
if (relChanges.length && !RELEASE_TYPE_EDITABLE.has(stateOf(v))) throw new Error(`App Store version ${versionString} (${pl}) is ${stateOf(v)}: its release type can no longer be changed (apple.releaseType wants ${changeText(relChanges)})`);
// 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]=${platform}`))[0];
@ -123,7 +128,7 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
const gc = gcUse.inUse ? await readGcSwitch(c, appId, v) : null;
const gcPlan = gc ? gcSwitchPlan(gc) : [];
if (a.has('json')) {
printJson({ dryRun: dry, releaseKind: kind.kind, gameCenter: gcPlan, version: { id: v.id, versionString, platform, state: stateOf(v) }, build, submission: state.choice ?? null, resolve: marks, rows, skipped: plan.skipped, blockers });
printJson({ dryRun: dry, releaseKind: kind.kind, releaseType: relChanges.length ? { changes: relChanges } : null, gameCenter: gcPlan, version: { id: v.id, versionString, platform, state: stateOf(v) }, build, submission: state.choice ?? null, resolve: marks, rows, skipped: plan.skipped, blockers });
// the desktop's owner dialog shows the output text: the plan table goes with the result there
if (process.env.STOREBOUND_MACHINE === '1') { console.log(formatRows(rows)); for (const x of blockers) warn(TAG, x); }
if (blockers.length) process.exitCode = 1;
@ -132,6 +137,7 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
} else {
console.log(formatRows(rows));
log(TAG, `${kind.kind === 'update' ? 'update of an app on sale' : 'FIRST release of this app (the owner approves it once: --first-release)'}: ${kind.why}`);
if (relChanges.length) log(TAG, `${dry ? 'would set' : 'setting'} release type: ${changeText(relChanges)} (apple.releaseType)`);
for (const l of gcPlan) log(TAG, `${dry ? 'would ' : ''}${l} (Game Center: ${gcUse.why})`);
for (const s of plan.skipped) log(TAG, `not added ${s.id}: ${s.why}`);
for (const x of blockers) warn(TAG, x);
@ -149,6 +155,10 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
await c.asc('PATCH', `/v1/appStoreVersions/${v.id}/relationships/build`, { data: { type: 'builds', id: build.id } });
log(TAG, `build ${number} attached to ${versionString}`);
}
if (relChanges.length) {
await c.asc('PATCH', `/v1/appStoreVersions/${v.id}`, { data: { type: 'appStoreVersions', id: v.id, attributes: Object.fromEntries(relChanges.map(ch => [ch.field, ch.to])) } });
log(TAG, `release type of ${versionString}: ${changeText(relChanges)}`);
}
if (gc && gcPlan.length) await ensureGcSwitch(c, gc, TAG);
try {
await submitPlanned(ctx, c, { appId, ids, state, tag: TAG, withVersion: true, appVersionId: v.id, platform, quiet: true, resolve });

View file

@ -26,7 +26,7 @@ import { BUILD_WAIT_EXIT, type BuildSeen, expireCandidates, planExpire, validHin
import { parseDuration } from '../lib/release';
import {
type ApiBuild, betaDescriptions, betaReadiness, betaReviewReq, buildsWithVersion, getsAllBuilds, type Group, groupFromApi, inviteReq, maskEmail,
parseTesters, pickBuild, planBetaInfo, planBetaReviewDetail, planDistribute, planGroups, planTesters, planWhatToTest, unknownGroupMessage, whatToTestFromListing,
installableBuild, noInstallableBuildHint, noInstallableBuilds409, parseTesters, pickBuild, planBetaInfo, planBetaReviewDetail, planDistribute, planGroups, planTesters, planWhatToTest, unknownGroupMessage, whatToTestFromListing,
} from '../lib/testflight';
import { type Asc, ascClient, type J } from './asc';
import { describeReq, execute, type Req } from './jsonapi';
@ -113,6 +113,17 @@ async function findBuild(c: Asc, appId: string, number: string, marketing?: stri
return { ...b, detail };
}
/** Whether the group has a build its testers can install now (past Beta App Review for an external group). An internal
* all-builds group has every build of the app. */
async function groupHasInstallableBuild(c: Asc, appId: string, g: Group): Promise<boolean> {
const builds = getsAllBuilds(g) ? await c.all(`/v1/builds?filter[app]=${appId}&limit=200`) : await c.all(`/v1/betaGroups/${g.id}/builds?limit=200`);
for (const b of builds) {
const detail = (await c.maybe(`/v1/builds/${b.id}/buildBetaDetail`))?.data?.attributes ?? null;
if (installableBuild(b, detail, g.internal)) return true;
}
return false;
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const a = ctx.args;
@ -193,9 +204,35 @@ export async function run(ctx: Ctx): Promise<void> {
await execute(c.asc, reqs, { dry, tag: TAG });
if (a.has('invite')) {
const fresh = new Map((await c.all(`/v1/betaTesters?filter[apps]=${appId}&limit=200`)).map((t: J) => [String(t.attributes.email ?? '').toLowerCase(), t] as const));
const pending = want.map(t => fresh.get(t.email)).filter((t): t is J => !!t && ['NOT_INVITED', 'INVITED'].includes(t.attributes.state));
if (pending.length && await confirm(ctx, `(Re)send the TestFlight invitation email to ${pending.length} tester(s)?`))
await execute(c.asc, pending.map(t => inviteReq(appId, t.id, maskEmail(String(t.attributes.email)))), { dry: false, tag: TAG });
// an invitation needs a build the tester can install; without one Apple answers 409 NO_INSTALLABLE_BUILDS (seen live
// on a new external group): add the tester, skip the invite, say when it can go out
const installable = new Map<string, boolean>();
const ready: { t: J; groups: string[] }[] = [], waiting = new Set<string>();
let waitingTesters = 0;
for (const w of want) {
const t = fresh.get(w.email);
if (!t || !['NOT_INVITED', 'INVITED'].includes(t.attributes.state)) continue;
const own = gs.filter(g => w.groups.includes(g.name));
for (const g of own) if (!installable.has(g.id)) installable.set(g.id, await groupHasInstallableBuild(c, appId, g));
if (own.some(g => installable.get(g.id))) ready.push({ t, groups: own.map(g => g.name) });
else { waitingTesters++; for (const g of own) waiting.add(g.name); }
}
if (waitingTesters) {
const msg = `${waitingTesters} ${noInstallableBuildHint([...waiting])}`;
warn(TAG, msg);
}
if (ready.length && await confirm(ctx, `(Re)send the TestFlight invitation email to ${ready.length} tester(s)?`)) {
for (const { t, groups } of ready) {
try { await execute(c.asc, [inviteReq(appId, t.id, maskEmail(String(t.attributes.email)))], { dry: false, tag: TAG }); }
catch (e) {
if (!noInstallableBuilds409(e)) throw e;
const msg = `1 ${noInstallableBuildHint(groups)}`;
warn(TAG, msg);
}
}
}
}
done(TAG, 'testers done');
return;
@ -293,6 +330,12 @@ export async function run(ctx: Ctx): Promise<void> {
if (await confirm(ctx, `Submit build ${number} to Beta App Review now? Apple reviews it for external testing (Test Information must be complete: storebound apple testflight info).`))
await execute(c.asc, [betaReviewReq(b.id)], { dry: false, tag: TAG });
}
// testers added to an external group but not invited yet: the invitation needs a build past Beta App Review
if (a.has('submit-review')) {
const waiting: string[] = [];
for (const g of external) if ((await c.all(`/v1/betaGroups/${g.id}/betaTesters?limit=200`)).some((t: J) => t.attributes.state === 'NOT_INVITED')) waiting.push(g.name);
if (waiting.length) { log(TAG, noInstallableBuildHint(waiting)); }
}
const added = target.filter(g => reqs.some(r => r.path.includes(`/betaGroups/${g.id}/`))).map(g => g.name);
done(TAG, `build ${number}: ${added.length ? `added to ${added.join(', ')}` : `already available to ${target.map(g => g.name).join(', ')}`}`);
return;

View file

@ -38,6 +38,7 @@ import { iphoneDuoNote } from './shots/spec';
import { hitLine } from './lib/cross-store';
import { findFastlaneTraces, traceMessage } from './lib/fastlane-traces';
import { listingFileMentions } from './lib/listing-mentions';
import { projectFamilies } from './lib/device-families';
/** The newest .ipa in a folder (apple build's export), or null. */
function newestIpa(dir: string): string | null {
@ -173,6 +174,14 @@ export async function run(ctx: Ctx): Promise<void> {
note('shots.capture.auth signs in to a local stack with reviewDemoAccount, the account App Review gets from apple review-details: put the local demo sign-in in captureAuth in .storebound/credentials.json and use ${cred:<name>}'); }
const duo = iphoneDuoNote({ apple: !!config.apple, duo: !!config.apple?.iphoneDuo, play: !!config.play });
if (duo?.level === 'info') { hints.push(duo.msg); log('check', `hint: ${duo.msg}`); } else if (duo) note(duo.msg);
// device families: offline only the project and the config can be compared; the live version's families need the store
if (config.apple) {
const proj = projectFamilies(ctx.loaded.root, config.apple.bundleId, config.apple.build?.project);
const iosOnly = !proj || !proj.families.ipad;
if (proj && proj.families.ipad && config.apple.ipad !== true) note(`${proj.file} builds for iPad (TARGETED_DEVICE_FAMILY ${proj.configurations.join(' / ')}) but apple.ipad is not true: iPad screenshots are not required or checked (set apple.ipad: true)`);
else if (proj && !proj.families.ipad && config.apple.ipad === true) note(`apple.ipad is true but ${proj.file} builds TARGETED_DEVICE_FAMILY ${proj.configurations.join(' / ')} (no iPad): set "1,2" or apple.ipad: false`);
else if (iosOnly && config.apple.ipad !== true) note(`device families: ${proj ? `iPhone only (apple.ipad not true, TARGETED_DEVICE_FAMILY ${proj.configurations.join(' / ')})` : 'no Xcode project found here'}, and the families of the live version are not known offline: if it supports iPad, Apple refuses an iPhone-only upload after the build (ITMS-90101). storebound validate and apple build compare with the store first`);
}
const creative = creativeNote(config, ctx.loaded.resolve(config.renders ?? config.shots?.out ?? 'store/renders'));
if (creative) { hints.push(creative); log('check', `hint: ${creative}`); }
// the scaffolder's own placeholders would reach the stores as the app's copyright, contact and legal pages

View file

@ -89,7 +89,7 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
'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 places it once Apple approved it, reporting waiting ones per locale (MD5 upsert, safe to re-run); --add-for-review, --verify (read-only), --dry'],
'apple previews': [() => import('./apple/previews'), 'app preview videos per locale (MD5-idempotent, poster frame time code); --dry'],
'apple release': [() => import('./apple/release'), 'phased release on|pause|resume|complete, --manual-release (asks first; --yes for an update of an app on sale, exit 7 when already released; the first-ever release is exit 3 OWNER_STEP unless --first-release); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
'apple release': [() => import('./apple/release'), 'phased release on|pause|resume|complete, --after-approval | --manual | --scheduled <date> (how an unapproved version goes live; exit 7 when already that type), --manual-release (asks first; --yes for an update of an app on sale, exit 7 when already released; the first-ever release is exit 3 OWNER_STEP unless --first-release); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
'apple signing': [() => import('./apple/signing'), 'status | ensure: bundle id capabilities, distribution certificate (key kept in .storebound/signing), App Store profiles installed for Xcode; --dry, asks first'],
'apple build': [() => import('./apple/build'), 'xcodebuild archive + exportArchive (ExportOptions.plist generated, next build number, API key signing); Capacitor, React Native, Flutter, Unity; --platform macos --tauri <src-tauri> [--path-free] builds a Tauri app for the Mac App Store (signed .app, sandbox entitlements, profile, productbuild .pkg); --dry'],
'apple upload': [() => import('./apple/upload'), 'upload an IPA, or with --platform macos a signed .pkg (App Store Connect API build upload, or --via altool), build number checked, waits for processing; --dry, asks first'],

View file

@ -68,6 +68,7 @@
],
"apple build": [
"allow-app-group-gap",
"allow-device-gap",
"allow-ssh",
"allow-stale-web",
"allow-version-mismatch",
@ -229,13 +230,16 @@
"enable"
],
"apple release": [
"after-approval",
"env-file",
"first-release",
"manual",
"manual-release",
"phased",
"platform",
"progress",
"result-file",
"scheduled",
"version",
"version-from"
],
@ -1145,12 +1149,14 @@
"accounts",
"add-for-review",
"adgroup",
"after-approval",
"against",
"agent",
"all",
"all-pages",
"allow-app-group-gap",
"allow-availability-changes",
"allow-device-gap",
"allow-existing",
"allow-price-changes",
"allow-ssh",
@ -1265,6 +1271,7 @@
"locale",
"locale-map",
"lookup",
"manual",
"manual-release",
"max-level",
"max-ratio",
@ -1348,6 +1355,7 @@
"root",
"scan-paths",
"scenes",
"scheduled",
"scheme",
"sdk-dirs",
"search",
@ -1526,7 +1534,8 @@
],
"apple release": [
"storebound apple release --phased on|pause|resume|complete [--version 1.2.0] [--dry] [--yes]",
"storebound apple release --manual-release [--version 1.2.0] [--first-release] [--yes] [--json]"
"storebound apple release --manual-release [--version 1.2.0] [--first-release] [--yes] [--json]",
"storebound apple release --after-approval | --manual | --scheduled <ISO date> [--version 1.2.0] [--first-release] [--yes] [--json]"
],
"apple resubmit": [
"storebound apple resubmit --build <n> [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--first-release] [--dry] [--yes] [--json]"

View file

@ -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
View 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;
}

View file

@ -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
View 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(', ');

View file

@ -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));

View file

@ -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);

View file

@ -38,6 +38,8 @@ import { playClient } from './play/client';
import { listOneTimeProducts } from './play/products';
import { creativeNote } from './shots/creative';
import { creativeWaiting, localCreative } from './apple/creative-assets';
import { liveDeviceFamilies } from './apple/device-families';
import { type FamilyVerdict, familyVerdicts, projectFamilies } from './lib/device-families';
import { fixFor, GUIDELINE, hitLine } from './lib/cross-store';
import { listingFileMentions } from './lib/listing-mentions';
import { brokerConfig, brokerPlayInfo, enabledApisOf, grantsOf, type PlayNeed, playGaps, sdkConfigGaps } from './lib/broker';
@ -98,6 +100,14 @@ async function creativeWaitingOf(ctx: Ctx, c: Awaited<ReturnType<typeof ascClien
} catch { return []; }
}
/** Live version's device families against the project and apple.ipad (cli/lib/device-families.ts). A failed read is a warning, never a blocker. */
async function deviceFamilyVerdicts(ctx: Ctx, c: Awaited<ReturnType<typeof ascClient>>, appId: string): Promise<FamilyVerdict[]> {
const { config, root } = ctx.loaded;
try {
return familyVerdicts({ live: await liveDeviceFamilies(c, appId), project: projectFamilies(root, config.apple!.bundleId, config.apple?.build?.project), ipad: config.apple?.ipad });
} catch (e) { return [{ level: 'warn', msg: `device families not compared: ${(e as Error).message.slice(0, 200)}` }]; }
}
export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
const { config, root } = ctx.loaded;
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
@ -173,6 +183,7 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
creativeWaiting: await creativeWaitingOf(ctx, c, appId),
screenshotsPending: pendingScreenshots(shotRows, updateLedger(root, shotRows), Date.now()),
configCopyright: config.app.copyright,
deviceFamilies: platform === 'IOS' ? await deviceFamilyVerdicts(ctx, c, appId) : undefined,
build, locales, reviewDetail, ageRating,
expectedLocales: config.languages.map(l => l.apple).filter((x): x is string => !!x),
requiredDisplayTypes: requiredDisplayTypes(config.apple),