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

@ -3,6 +3,10 @@
## Unreleased
- Release type. `apple.releaseType: afterApproval | manual | scheduled` (+ `apple.earliestReleaseDate`): `apple listing` creates versions with it (MANUAL when unset, existing versions untouched) and `apple submit` / `resubmit` set it before submitting when it differs, the dry run showing old -> new. New `apple release --after-approval | --manual | --scheduled <ISO date> [--platform] [--version] [--first-release] --yes` changes it on a version App Review has not approved yet (clear refusal otherwise, with `--manual-release` as the way out); exit 7 when it already has that type; an update is an agent step, after-approval on the first version of an app is exit 3 OWNER_STEP. Pitfall 125.
- Device families. `validate` and `apple build` read the device families of the last live version (build bundle `deviceProtocols`, device models, iPad screenshot sets) and compare them with TARGETED_DEVICE_FAMILY in the Xcode project and `apple.ipad`; a gap stops `apple build` before the archive with the exact fix (ITMS-90101 otherwise comes after the build; `--allow-device-gap` overrides). Offline `check` warns when the live families are unknown or the config and project disagree. Pitfall 126.
- TestFlight. `apple testflight testers --invite` checks that the tester's group has an installable build (past Beta App Review for external groups); without one it adds the tester, skips the invite and prints when it can go out (exit 0). Apple's 409 NO_INSTALLABLE_BUILDS is handled the same way; `distribute --submit-review` prints the hint for testers not invited yet. Pitfall 127.
## 0.8.55 · 2026-10-26
- `apple build --path-free`: a checkout in a shared build folder outside the home folder is no longer a leak needle (dsymutil and the module cache record it; only home and user-name paths count).

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),

View file

@ -76,7 +76,7 @@ answers the compliance facts no file proves, all in one batch at the start.
14. **Submit.** `storebound apple submit --build <n>` (dry run: the plan table with the build you uploaded, by its
number, the version and the ready IAPs in one review submission), then the owner's yes for the same command with
`--yes`; with IAPs `apple iap-submit --with-version --yes` does the same without attaching a build (step 11). In
the desktop app the owner presses Releases > Submit for review (plan, then a system dialog). Release type MANUAL
the desktop app the owner presses Releases > Submit for review (plan, then a system dialog). Release type MANUAL (or apple.releaseType: afterApproval | manual | scheduled; `apple release --after-approval | --manual | --scheduled <date>` changes it until the version is approved; for the first version of an app after-approval is the owner's step)
means the owner presses "Release" after approval.
15. **Verify + wait.** `storebound apple status`: version WAITING_FOR_REVIEW, IAPs WAITING_FOR_REVIEW, one review
submission. `storebound apple status --wait-review` polls until approved (exit 0) or rejected (exit 1,

View file

@ -35,7 +35,12 @@ presses release; you prepare, dry-run and report. `storebound mcp` deliberately
already); the first-ever submission is the owner's (exit 3 `OWNER_STEP`, re-run with `--first-release` after the
owner's yes). Wait: `storebound apple status --wait-review` (polls with backoff, exit 0 approved, 1 rejected,
7 timeout; `--timeout 72h`). Rejected: `review-rejections` skill.
4. Approved = PENDING_DEVELOPER_RELEASE. Release an update yourself: `storebound apple release --manual-release --yes`
4. Release type: an update that should go live by itself is `afterApproval`. Set `apple.releaseType: afterApproval` in the
config (`apple listing` applies it to a new version, `apple submit` sets it before submitting and its dry run shows
`releaseType MANUAL -> AFTER_APPROVAL`), or on one version `storebound apple release --after-approval --yes`
(`--manual` / `--scheduled <ISO date>` are the other two; allowed until App Review approves the version, exit 7 when it
is that type already; pitfall 125). Without it an approved MANUAL version waits in PENDING_DEVELOPER_RELEASE.
Approved = PENDING_DEVELOPER_RELEASE. Release an update yourself: `storebound apple release --manual-release --yes`
(the first-ever release of an app is the owner's: `OWNER_STEP`) or the "Release This Version" button. With phased release on, automatic updates reach 1 %, 2 %, 5 %, 10 %, 20 %,
50 %, 100 % over 7 days; anyone can still update manually from the store.
5. During the rollout watch crashes / reviews. Problem: `storebound apple release --phased pause` (up to 30 days in

View file

@ -1547,3 +1547,41 @@ that fails without the fix (see CONTRIBUTING.md).
are not the user's and are not hits. A prebuilt vendored framework carries its own paths: rebuild it path-free.
- **Never:** point a shared build folder at the default DerivedData, or run the privacy scan only after the upload.
- **Guard:** `cli/lib/path-free.ts`, `tests/path-free.test.ts`, the hint when `--out` is outside the home folder.
## 125. Updates sat in Pending Developer Release: the version was created and submitted with release type MANUAL
- **Symptom:** an approved update did not go live; it waited in PENDING_DEVELOPER_RELEASE because `apple listing` created the
version with releaseType MANUAL and `apple submit` never touched it.
- **Do:** set `apple.releaseType: afterApproval` (or `manual` / `scheduled` with `apple.earliestReleaseDate`). `apple listing`
creates new versions with it, `apple submit` / `apple resubmit` set it on the version before submitting when it differs
(the dry run shows `releaseType MANUAL -> AFTER_APPROVAL`). On one version: `storebound apple release --after-approval --yes`
(`--manual`, `--scheduled <ISO date>`; `--platform`, `--version`, `--json`; exit 7 when it already has that type). The type
is editable until App Review approves the version; an approved one is released with `apple release --manual-release`.
Unset: new versions are MANUAL and existing ones are left as they are. Updates are agent steps; `--after-approval` on the
first version of an app puts it on sale by itself, so that is the owner's (exit 3 `OWNER_STEP`, `--first-release`).
- **Never:** wait for an approval email and then look for the console button, or flip the type back with `apple listing` runs.
- **Guard:** `cli/lib/release-type.ts`, `tests/apple-release-gaps.test.ts`.
## 126. iPhone-only build of an app whose live version runs on iPad: ITMS-90101 after a full archive
- **Symptom:** `storebound check` passed with `apple.ipad` false; the upload was refused after the archive with ITMS-90101 "This
bundle does not support one or more of the devices supported by the previous app version" (TARGETED_DEVICE_FAMILY = 1).
- **Do:** `storebound validate` and `apple build` read the live version (READY_FOR_SALE, else the newest that was) from App
Store Connect: its build bundle (`deviceProtocols`, device models of `buildBundleFileSizes`) and its screenshot sets
(`APP_IPAD_*`); any iPad signal means iPad. They compare it with TARGETED_DEVICE_FAMILY of the Release configuration in the
Xcode project and with `apple.ipad`, and stop BEFORE the archive with the fix: `TARGETED_DEVICE_FAMILY = "1,2"` and
`apple.ipad: true`. Offline `check` warns when the live families cannot be known. `--allow-device-gap` builds anyway.
- **Never:** drop a device family the live version supports, or turn `apple.ipad` off to silence the screenshot check.
- **Guard:** `cli/lib/device-families.ts`, `cli/apple/device-families.ts`, `tests/apple-release-gaps.test.ts`. The build bundle
attribute names come from Apple's spec; which signal carries the iPad answer live is not recorded, hence the merge.
## 127. TestFlight invite on a new external group: 409 STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS
- **Symptom:** `apple testflight testers --group <g> --invite --yes` created the tester but the invitation failed with 409
NO_INSTALLABLE_BUILDS: the group had no build past Beta App Review yet.
- **Do:** `--invite` first checks that a build of the tester's group is installable (external: BETA_APPROVED / IN_BETA_TESTING;
internal: READY_FOR_BETA_TESTING / IN_BETA_TESTING). If not, the tester stays added, no invitation is sent and the run prints
"tester added; the invite goes out when a build in <group> passes Beta App Review (re-run with --invite then)", exit 0. The
409 itself is treated the same way. `apple testflight distribute --submit-review` prints the line for testers not invited yet.
- **Never:** retry the invitation in a loop, or report the added tester as a failure.
- **Guard:** `installableBuild` / `noInstallableBuildHint` in `cli/lib/testflight.ts`, `tests/apple-release-gaps.test.ts`.

View file

@ -39,7 +39,10 @@ changes or deletes a group.
commits; storebound masks them in every log line (`j***@example.com`).
- `storebound apple testflight testers --dry` shows: new testers, testers added to more groups, (with `--remove`)
testers who leave a managed group. The real run **asks first**: Apple emails each new tester an invitation once the
group has a build. `--invite` (re)sends invitations to testers still `NOT_INVITED` / `INVITED` (asks again).
group has a build. `--invite` (re)sends invitations to testers still `NOT_INVITED` / `INVITED` (asks again) once a build of the tester's group is
installable (an external group: past Beta App Review). Without one the tester is added, the invite is skipped and the run
says "tester added; the invite goes out when a build in <group> passes Beta App Review (re-run with --invite then)" (exit 0;
Apple's 409 `NO_INSTALLABLE_BUILDS` is handled the same way; `distribute --submit-review` prints the same line; pitfall 127).
- Internal groups only take App Store Connect users (Users and Access); add people there first.
- Testers are an owner action: not an MCP tool. An agent prepares the CSV format and the dry run, the owner runs it.

View file

@ -0,0 +1,405 @@
// Three gaps found in real use, against the stateful App Store Connect double (tests/helpers/fake-stores.ts):
// 1. release type: updates sat in Pending Developer Release because versions were created / submitted as MANUAL
// (apple.releaseType, apple submit, apple release --after-approval | --manual)
// 2. device families: an iPhone-only build of an app whose live version runs on iPad is refused AFTER the archive (ITMS-90101)
// 3. TestFlight: inviting a tester of a group without an installable build is 409 NO_INSTALLABLE_BUILDS
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
import { parseArgs } from '../cli/lib/args';
import { runShipStep } from '../cli/lib/ship-step';
import { run as appleSubmit } from '../cli/apple/submit';
import { run as appleRelease } from '../cli/apple/release';
import { run as appleTestflight } from '../cli/apple/testflight';
import { run as appleBuild } from '../cli/apple/build';
import { validateConfig } from '../cli/lib/config';
import { planVersion } from '../cli/lib/listing-plan';
import { releaseTypeChanges, validateReleaseType, wantedRelease } from '../cli/lib/release-type';
import { familyVerdicts, liveFamilies, pbxFamilies, projectFamilies } from '../cli/lib/device-families';
import { liveDeviceFamilies } from '../cli/apple/device-families';
import { appleRules } from '../cli/lib/validate-rules';
import { installableBuild, noInstallableBuildHint, noInstallableBuilds409 } from '../cli/lib/testflight';
import { ascEnv, BASE, capture, project, restore } from './helpers/store-mock';
import { installStores, type Stores, uninstallStores, writes } from './helpers/fake-stores';
const APP = '6400000101';
/** Runs a ship step in-process with stdout / stderr captured. */
async function ship(key: string, argv: string[], body: (a: ReturnType<typeof parseArgs>) => Promise<void>): Promise<{ code: number; out: string; err: string }> {
const out: string[] = [], err: string[] = [];
const o = process.stdout.write, e = process.stderr.write, cl = console.log, ce = console.error, cw = console.warn, ci = console.info;
process.stdout.write = ((s: string | Uint8Array) => { out.push(typeof s === 'string' ? s : new TextDecoder().decode(s)); return true; }) as typeof process.stdout.write;
process.stderr.write = ((s: string | Uint8Array) => { err.push(typeof s === 'string' ? s : new TextDecoder().decode(s)); return true; }) as typeof process.stderr.write;
console.log = (...a: unknown[]) => { out.push(a.map(String).join(' ') + '\n'); };
console.info = console.log;
console.error = (...a: unknown[]) => { err.push(a.map(String).join(' ') + '\n'); };
console.warn = console.error;
try {
const code = await runShipStep(key, argv, parseArgs, async a => {
const keep = console.log;
if (a.has('json')) console.log = (...x: unknown[]) => console.error(...x);
try { await body(a); } finally { console.log = keep; }
});
return { code, out: out.join(''), err: err.join('') };
} finally { process.stdout.write = o; process.stderr.write = e; console.log = cl; console.error = ce; console.warn = cw; console.info = ci; }
}
// ------------------------------------------------------------------------------------------------ 1. release type
describe('release type (pure)', () => {
test('config: afterApproval | manual | scheduled (+ date); scheduled needs the date, the date needs scheduled', () => {
expect(validateReleaseType({ releaseType: 'afterApproval' })).toEqual([]);
expect(validateReleaseType({ releaseType: 'scheduled', earliestReleaseDate: '2027-03-01T09:00:00Z' })).toEqual([]);
expect(validateReleaseType({ releaseType: 'soon' }).join()).toContain('afterApproval | manual | scheduled');
expect(validateReleaseType({ releaseType: 'scheduled' }).join()).toContain('earliestReleaseDate is required');
expect(validateReleaseType({ releaseType: 'manual', earliestReleaseDate: '2027-03-01T09:00:00Z' }).join()).toContain('only with apple.releaseType "scheduled"');
expect(validateReleaseType({ releaseType: 'scheduled', earliestReleaseDate: 'tomorrow' }).join()).toContain('ISO 8601');
expect(validateConfig({ ...BASE, apple: { ...BASE.apple!, releaseType: 'later' as never } }).join()).toContain('apple.releaseType');
expect(wantedRelease({})).toBeNull();
expect(wantedRelease({ releaseType: 'afterApproval' })).toEqual({ releaseType: 'AFTER_APPROVAL' });
});
test('changes are old -> new; the same instant is not a change', () => {
expect(releaseTypeChanges({ releaseType: 'MANUAL' }, { releaseType: 'AFTER_APPROVAL' })).toEqual([{ field: 'releaseType', from: 'MANUAL', to: 'AFTER_APPROVAL' }]);
expect(releaseTypeChanges({ releaseType: 'AFTER_APPROVAL' }, { releaseType: 'AFTER_APPROVAL' })).toEqual([]);
expect(releaseTypeChanges({ releaseType: 'SCHEDULED', earliestReleaseDate: '2027-03-01T09:00:00+00:00' }, { releaseType: 'SCHEDULED', earliestReleaseDate: '2027-03-01T09:00:00Z' })).toEqual([]);
expect(releaseTypeChanges({ releaseType: 'MANUAL' }, { releaseType: 'SCHEDULED', earliestReleaseDate: '2027-03-01T09:00:00Z' }).map(c => c.field)).toEqual(['releaseType', 'earliestReleaseDate']);
});
test('apple listing: a new version takes the configured type (MANUAL when unset); an existing one keeps its own unless asked', () => {
const rows = [{ id: 'v1', attributes: { versionString: '1.0.0', appVersionState: 'READY_FOR_SALE', platform: 'IOS' } }];
expect(planVersion(rows, '1.1.0', { copyright: 'c' }).attributes.releaseType).toBe('MANUAL');
expect(planVersion(rows, '1.1.0', { copyright: 'c', releaseType: 'AFTER_APPROVAL' }).attributes.releaseType).toBe('AFTER_APPROVAL');
const sched = planVersion(rows, '1.1.0', { copyright: 'c', releaseType: 'SCHEDULED', earliestReleaseDate: '2027-03-01T09:00:00Z' });
expect(sched.attributes).toMatchObject({ releaseType: 'SCHEDULED', earliestReleaseDate: '2027-03-01T09:00:00Z' });
const editable = [...rows, { id: 'v2', attributes: { versionString: '1.1.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS', copyright: 'c', releaseType: 'AFTER_APPROVAL' } }];
expect(planVersion(editable, '1.1.0', { copyright: 'c' }).action).toBe('keep');
expect(planVersion(editable, '1.1.0', { copyright: 'c', releaseType: 'MANUAL' }).changes).toEqual([{ field: 'releaseType', from: 'AFTER_APPROVAL', to: 'MANUAL' }]);
});
});
describe('release type (fake App Store Connect)', () => {
setDefaultTimeout(30_000);
let s: Stores;
beforeAll(() => { ascEnv(); });
afterAll(restore);
beforeEach(() => { s = installStores(); });
afterEach(() => { uninstallStores(); });
const version = (id: string, v: string, state: string, releaseType = 'MANUAL'): void => { s.asc.put({ type: 'appStoreVersions', id, attributes: { versionString: v, appVersionState: state, platform: 'IOS', releaseType }, rels: { app: APP } }); };
const build = (): void => { s.asc.put({ type: 'builds', id: 'b-8', attributes: { version: '8', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP } }); };
const typeOf = (id: string): unknown => s.asc.all('appStoreVersions').find(v => v.id === id)!.attributes.releaseType;
const cfg = (releaseType: 'afterApproval' | 'manual') => ({ ...BASE, apple: { ...BASE.apple!, releaseType } });
test('apple submit with apple.releaseType: the dry run shows old -> new, the real run sets it BEFORE the submit, the re-run is exit 7', async () => {
version('ver-0', '1.0.0', 'READY_FOR_SALE');
version('ver-1', '1.1.0', 'PREPARE_FOR_SUBMISSION');
build();
const p = project(cfg('afterApproval'));
const dry = await ship('apple submit', ['--json', '--build', '8'], a => appleSubmit({ loaded: p.loaded, args: a }));
expect(dry.code).toBe(0);
expect(JSON.parse(dry.out).releaseType).toEqual({ changes: [{ field: 'releaseType', from: 'MANUAL', to: 'AFTER_APPROVAL' }] });
expect(typeOf('ver-1')).toBe('MANUAL');
expect(writes(s.calls)).toEqual([]);
const r = await ship('apple submit', ['--json', '--yes', '--build', '8'], a => appleSubmit({ loaded: p.loaded, args: a }));
expect(r.err).not.toContain('error:');
expect(r.code).toBe(0);
expect(r.err).toContain('release type of 1.1.0: releaseType MANUAL -> AFTER_APPROVAL');
expect(typeOf('ver-1')).toBe('AFTER_APPROVAL');
const w = writes(s.calls).map(c => `${c.method} ${c.path}`);
expect(w.indexOf('PATCH /v1/appStoreVersions/ver-1')).toBeGreaterThanOrEqual(0);
expect(w.indexOf('PATCH /v1/appStoreVersions/ver-1')).toBeLessThan(w.findIndex(x => /^PATCH \/v1\/reviewSubmissions\//.test(x)));
const again = await ship('apple submit', ['--json', '--yes', '--build', '8'], a => appleSubmit({ loaded: p.loaded, args: a }));
expect(again.code).toBe(7);
});
test('apple submit without apple.releaseType leaves the version\'s release type alone', async () => {
version('ver-0', '1.0.0', 'READY_FOR_SALE');
version('ver-1', '1.1.0', 'PREPARE_FOR_SUBMISSION');
build();
const p = project(BASE);
const r = await ship('apple submit', ['--json', '--yes', '--build', '8'], a => appleSubmit({ loaded: p.loaded, args: a }));
expect(r.code).toBe(0);
expect(typeOf('ver-1')).toBe('MANUAL');
expect(writes(s.calls).some(c => c.path === '/v1/appStoreVersions/ver-1')).toBe(false);
});
test('apple release --after-approval --yes on an update: set, then exit 7 when it is that type; --manual puts it back', async () => {
version('ver-0', '1.0.0', 'READY_FOR_SALE');
version('ver-1', '1.1.0', 'WAITING_FOR_REVIEW');
const p = project(BASE);
const run = (argv: string[]) => ship('apple release', ['--json', '--yes', ...argv], a => appleRelease({ loaded: p.loaded, args: a }));
const r = await run(['--after-approval', '--version', '1.1.0']);
expect(r.err).not.toContain('error:');
expect(r.code).toBe(0);
expect(JSON.parse(r.out)).toMatchObject({ ok: true, step: 'release', version: '1.1.0', releaseType: 'AFTER_APPROVAL', from: 'MANUAL', changed: true, releaseKind: 'update' });
expect(writes(s.calls).map(c => `${c.method} ${c.path}`)).toEqual(['PATCH /v1/appStoreVersions/ver-1']);
expect(typeOf('ver-1')).toBe('AFTER_APPROVAL');
const n = s.calls.length;
const again = await run(['--after-approval']);
expect(again.code).toBe(7);
expect(JSON.parse(again.out)).toMatchObject({ ok: true, alreadyThere: true });
expect(writes(s.calls.slice(n))).toEqual([]);
const back = await run(['--manual', '--version', '1.1.0']);
expect(back.code).toBe(0);
expect(typeOf('ver-1')).toBe('MANUAL');
});
test('apple release --after-approval --dry changes nothing; --scheduled sets the date; two type flags are a usage error', async () => {
version('ver-0', '1.0.0', 'READY_FOR_SALE');
version('ver-1', '1.1.0', 'PREPARE_FOR_SUBMISSION');
const p = project(BASE);
const dry = await ship('apple release', ['--json', '--after-approval', '--dry'], a => appleRelease({ loaded: p.loaded, args: a }));
expect(dry.code).toBe(0);
expect(JSON.parse(dry.out)).toMatchObject({ dryRun: true, changes: [{ field: 'releaseType', from: 'MANUAL', to: 'AFTER_APPROVAL' }] });
expect(writes(s.calls)).toEqual([]);
const sch = await ship('apple release', ['--json', '--yes', '--scheduled', '2027-03-01T09:00:00Z'], a => appleRelease({ loaded: p.loaded, args: a }));
expect(sch.code).toBe(0);
expect(s.asc.all('appStoreVersions').find(v => v.id === 'ver-1')!.attributes).toMatchObject({ releaseType: 'SCHEDULED', earliestReleaseDate: '2027-03-01T09:00:00Z' });
const both = await ship('apple release', ['--json', '--yes', '--manual', '--after-approval'], a => appleRelease({ loaded: p.loaded, args: a }));
expect(both.code).not.toBe(0);
expect(both.err).toContain('alternatives');
});
test('an approved version (Pending Developer Release) is refused with the way out; a released one too', async () => {
version('ver-0', '1.0.0', 'READY_FOR_SALE');
version('ver-1', '1.1.0', 'PENDING_DEVELOPER_RELEASE');
const p = project(BASE);
const r = await ship('apple release', ['--json', '--yes', '--after-approval', '--version', '1.1.0'], a => appleRelease({ loaded: p.loaded, args: a }));
expect(r.code).not.toBe(0);
expect(r.err).toContain('apple release --manual-release --version 1.1.0');
expect(writes(s.calls)).toEqual([]);
// already MANUAL and asked for MANUAL: nothing to do even in that state
const same = await ship('apple release', ['--json', '--yes', '--manual', '--version', '1.1.0'], a => appleRelease({ loaded: p.loaded, args: a }));
expect(same.code).toBe(7);
});
test('--after-approval on the first version of an app is the owner\'s step (exit 3 OWNER_STEP) unless --first-release', async () => {
version('ver-1', '1.0.0', 'PREPARE_FOR_SUBMISSION');
const p = project(BASE);
const r = await ship('apple release', ['--json', '--yes', '--after-approval'], a => appleRelease({ loaded: p.loaded, args: a }));
expect(r.code).toBe(3);
expect(JSON.parse(r.out)).toMatchObject({ ok: false, error: { code: 'OWNER_STEP' } });
expect(writes(s.calls)).toEqual([]);
const manual = await ship('apple release', ['--json', '--yes', '--manual'], a => appleRelease({ loaded: p.loaded, args: a }));
expect(manual.code).toBe(7);
const ok = await ship('apple release', ['--json', '--yes', '--after-approval', '--first-release'], a => appleRelease({ loaded: p.loaded, args: a }));
expect(ok.code).toBe(0);
expect(typeOf('ver-1')).toBe('AFTER_APPROVAL');
});
});
// ------------------------------------------------------------------------------------------------ 2. device families
const PBX = (family: string, bundle = 'com.example.notes'): string => `// !$*UTF8*$!
{
objects = {
/* Begin XCBuildConfiguration section */
AA01 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
PRODUCT_BUNDLE_IDENTIFIER = ${bundle};
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
AA02 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = ZZ01;
buildSettings = {
PRODUCT_BUNDLE_IDENTIFIER = ${bundle};
TARGETED_DEVICE_FAMILY = ${family};
};
name = Release;
};
AA03 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
PRODUCT_BUNDLE_IDENTIFIER = ${bundle}.Share;
TARGETED_DEVICE_FAMILY = "1,2";
WRAPPER_EXTENSION = appex;
};
name = Release;
};
/* End XCBuildConfiguration section */
};
}
`;
describe('device families (pure)', () => {
test('pbxproj: the Release configuration of the app\'s bundle id, not the extension\'s or Debug', () => {
expect(pbxFamilies(PBX('1'), 'com.example.notes')).toMatchObject({ list: [1], families: { iphone: true, ipad: false }, configurations: ['1'] });
expect(pbxFamilies(PBX('"1,2"'), 'com.example.notes')).toMatchObject({ list: [1, 2], families: { iphone: true, ipad: true } });
expect(pbxFamilies('no project here', 'com.example.notes')).toBeNull();
});
test('live signals: any iPad signal means iPad; no signal at all is unknown, never iPhone only', () => {
expect(liveFamilies('1.0.0', 'READY_FOR_SALE', [{ name: 'screenshot sets', values: ['APP_IPHONE_67', 'APP_IPAD_PRO_3GEN_129'] }])?.families).toEqual({ iphone: true, ipad: true });
expect(liveFamilies('1.0.0', 'READY_FOR_SALE', [{ name: 'a', values: [] }, { name: 'b', values: ['APP_IPHONE_67'] }])?.families).toEqual({ iphone: true, ipad: false });
expect(liveFamilies('1.0.0', 'READY_FOR_SALE', [{ name: 'a', values: [] }])).toBeNull();
});
test('verdicts: the exact fix; unknown is a warning; iPhone-only live version is fine', () => {
const live = liveFamilies('1.0.0', 'READY_FOR_SALE', [{ name: 'screenshot sets', values: ['APP_IPAD_PRO_3GEN_129'] }]);
const project = pbxFamilies(PBX('1'), 'com.example.notes', 'ios/App/App.xcodeproj/project.pbxproj');
const v = familyVerdicts({ live, project, ipad: false });
expect(v.map(x => x.level)).toEqual(['blocker', 'blocker']);
expect(v[0]!.msg).toContain('ITMS-90101');
expect(v[0]!.fix).toContain('"1,2"');
expect(v[1]!.fix).toContain('apple.ipad: true');
expect(familyVerdicts({ live, project: pbxFamilies(PBX('"1,2"'), 'com.example.notes'), ipad: true }).map(x => x.level)).toEqual(['ok']);
expect(familyVerdicts({ live: null, project, ipad: false })[0]!.level).toBe('warn');
expect(familyVerdicts({ live: liveFamilies('1.0.0', 'READY_FOR_SALE', [{ name: 'a', values: ['APP_IPHONE_67'] }]), project, ipad: false })[0]!.level).toBe('ok');
});
test('validate rules turn the verdicts into findings', () => {
const f = appleRules({ deviceFamilies: [{ level: 'blocker', msg: 'x ITMS-90101', fix: 'y' }], version: null, build: null, expectedLocales: [], locales: [], requiredDisplayTypes: [], reviewDetail: null, ageRating: null, priceSchedule: true, availability: true, iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com', status: 200 } }).filter(x => x.code === 'device-families');
expect(f).toMatchObject([{ store: 'apple', level: 'blocker', code: 'device-families', fix: 'y' }]);
});
});
describe('device families (fake App Store Connect + project)', () => {
setDefaultTimeout(30_000);
let s: Stores;
beforeAll(() => { ascEnv(); });
afterAll(restore);
beforeEach(() => { s = installStores(); });
afterEach(() => { uninstallStores(); });
/** a live version 1.0.0 with a build whose bundle names the families; screenshotSets: display types of its first locale */
function live(o: { protocols?: string[]; sets?: string[]; models?: string[] }): void {
s.asc.put({ type: 'appStoreVersions', id: 'ver-live', attributes: { versionString: '1.0.0', appVersionState: 'READY_FOR_SALE', platform: 'IOS' }, rels: { app: APP } });
s.asc.put({ type: 'builds', id: 'b-live', attributes: { version: '4', processingState: 'VALID' }, rels: { app: APP, appStoreVersion: 'ver-live' } });
s.asc.put({ type: 'buildBundles', id: 'bb-live', attributes: { bundleId: 'com.example.notes', bundleType: 'APP', deviceProtocols: o.protocols ?? [] }, rels: { build: 'b-live' } });
for (const [i, m] of (o.models ?? []).entries()) s.asc.put({ type: 'buildBundleFileSizes', id: `fs-${i}`, attributes: { deviceModel: m }, rels: { buildBundle: 'bb-live' } });
if (o.sets) {
s.asc.put({ type: 'appStoreVersionLocalizations', id: 'loc-live', attributes: { locale: 'en-US' }, rels: { appStoreVersion: 'ver-live' } });
for (const [i, t] of o.sets.entries()) s.asc.put({ type: 'appScreenshotSets', id: `set-${i}`, attributes: { screenshotDisplayType: t }, rels: { appStoreVersionLocalization: 'loc-live' } });
}
}
const IPHONE_ONLY = { 'ios/App/App.xcodeproj/project.pbxproj': PBX('1') };
const cfg = { ...BASE, apple: { ...BASE.apple!, ipad: false, signing: { teamId: 'ABCDE12345' } } };
test('the live version\'s families are read from the build bundle, its device models and its screenshot sets', async () => {
const c = (await import('../cli/apple/asc')).ascClient;
live({ sets: ['APP_IPHONE_67', 'APP_IPAD_PRO_3GEN_129'] });
expect((await liveDeviceFamilies(await c('.', 'com.example.notes', APP), APP))?.families).toEqual({ iphone: true, ipad: true });
uninstallStores(); s = installStores();
live({ protocols: ['iphone', 'ipad'] });
expect((await liveDeviceFamilies(await c('.', 'com.example.notes', APP), APP))?.evidence[0]).toContain('deviceProtocols');
uninstallStores(); s = installStores();
live({ models: ['iPad_Pro_12_9_6th', 'iPhone15,2'] });
expect((await liveDeviceFamilies(await c('.', 'com.example.notes', APP), APP))?.families.ipad).toBe(true);
uninstallStores(); s = installStores();
expect(await liveDeviceFamilies(await c('.', 'com.example.notes', APP), APP)).toBeNull();
});
test('apple build stops BEFORE the archive when the project drops iPad that the live version has; --allow-device-gap goes on', async () => {
live({ sets: ['APP_IPHONE_67', 'APP_IPAD_PRO_3GEN_129'] });
const argv = ['--build-number', '5', '--no-prebuild'];
const p = project(cfg, IPHONE_ONLY);
const r = await capture(() => appleBuild(p.ctx(argv))).catch(e => ({ out: '', err: String((e as Error).message ?? e), exit: 1 }));
expect(r.exit).toBe(1);
expect(r.err).toContain('stopped before the archive');
expect(r.err).toContain('ITMS-90101');
expect(r.err).toContain('TARGETED_DEVICE_FAMILY to "1,2"');
expect(r.err).toContain('apple.ipad: true');
expect(r.err).toContain('--allow-device-gap');
// dry: warns, never throws
const dry = await capture(() => appleBuild(p.ctx([...argv, '--dry'])));
expect(dry.err).toContain('ITMS-90101');
// the escape hatch passes the check (the run then stops later: no Xcode on this machine)
const gap = await capture(() => appleBuild(p.ctx([...argv, '--allow-device-gap']))).catch(e => ({ out: '', err: String((e as Error).message ?? e), exit: 1 }));
expect(gap.err).not.toContain('stopped before the archive');
});
test('apple build passes the check with iPad in the project and apple.ipad true; an iPhone-only live version never blocks', async () => {
live({ sets: ['APP_IPHONE_67', 'APP_IPAD_PRO_3GEN_129'] });
const ok = project({ ...cfg, apple: { ...cfg.apple, ipad: true } }, { 'ios/App/App.xcodeproj/project.pbxproj': PBX('"1,2"') });
const r = await capture(() => appleBuild(ok.ctx(['--build-number', '5', '--no-prebuild', '--dry'])));
expect(r.err).not.toContain('ITMS-90101');
uninstallStores(); s = installStores();
live({ sets: ['APP_IPHONE_67'] });
const iph = project(cfg, IPHONE_ONLY);
const r2 = await capture(() => appleBuild(iph.ctx(['--build-number', '5', '--no-prebuild', '--dry'])));
expect(r2.err).not.toContain('ITMS-90101');
});
test('projectFamilies reads the project found under the root (and apple.build.project)', () => {
const p = project(cfg, IPHONE_ONLY);
expect(projectFamilies(p.root, 'com.example.notes')).toMatchObject({ file: 'ios/App/App.xcodeproj/project.pbxproj', list: [1] });
expect(projectFamilies(p.root, 'com.example.notes', 'ios/App/App.xcodeproj')?.list).toEqual([1]);
expect(projectFamilies(project(cfg).root, 'com.example.notes')).toBeNull();
});
});
// ------------------------------------------------------------------------------------------------ 3. TestFlight invite
describe('TestFlight invite needs an installable build', () => {
setDefaultTimeout(30_000);
let s: Stores;
beforeAll(() => { ascEnv(); });
afterAll(restore);
beforeEach(() => { s = installStores(); });
afterEach(() => { uninstallStores(); });
const CSV = { '.storebound/testers.csv': 'email,first_name,groups\ntester1@example.com,Ex,Friends\n' };
const cfg = { ...BASE, apple: { ...BASE.apple!, testflight: { groups: [{ name: 'Friends' }] } } };
const group = (): void => { s.asc.put({ type: 'betaGroups', id: 'grp-friends', attributes: { name: 'Friends', isInternalGroup: false }, rels: { app: APP } }); };
const buildIn = (external: string): void => {
s.asc.put({ type: 'builds', id: 'b-1', attributes: { version: '7', processingState: 'VALID', expired: false }, rels: { app: APP, betaGroups: ['grp-friends'] } });
s.asc.put({ type: 'buildBetaDetails', id: 'bd-1', attributes: { externalBuildState: external, internalBuildState: 'IN_BETA_TESTING' }, rels: { build: 'b-1' } });
};
const invites = (): number => s.asc.all('betaTesterInvitations').length;
test('pure: external needs BETA_APPROVED / IN_BETA_TESTING, internal READY_FOR_BETA_TESTING / IN_BETA_TESTING', () => {
const b = { attributes: { processingState: 'VALID' } };
expect(installableBuild(b, { externalBuildState: 'READY_FOR_BETA_SUBMISSION' }, false)).toBe(false);
expect(installableBuild(b, { externalBuildState: 'WAITING_FOR_BETA_REVIEW' }, false)).toBe(false);
expect(installableBuild(b, { externalBuildState: 'BETA_APPROVED' }, false)).toBe(true);
expect(installableBuild(b, { externalBuildState: 'IN_BETA_TESTING' }, false)).toBe(true);
expect(installableBuild(b, { internalBuildState: 'READY_FOR_BETA_TESTING' }, true)).toBe(true);
expect(installableBuild({ attributes: { processingState: 'VALID', expired: true } }, { externalBuildState: 'BETA_APPROVED' }, false)).toBe(false);
expect(noInstallableBuildHint(['Friends'])).toBe('tester added; the invite goes out when a build in Friends passes Beta App Review (re-run with --invite then)');
expect(noInstallableBuilds409(new Error('POST /v1/betaTesterInvitations -> 409 STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS'))).toBe(true);
expect(noInstallableBuilds409(new Error('-> 409 ALREADY_EXISTS'))).toBe(false);
});
test('a new external group without an approved build: the tester is added, no invitation is sent, exit 0 with the hint', async () => {
group();
buildIn('WAITING_FOR_BETA_REVIEW');
const p = project(cfg, CSV);
const r = await capture(() => appleTestflight(p.ctx(['testers', '--group', 'Friends', '--invite', '--yes'])));
expect(r.exit).toBeNull();
expect(r.err).toContain('tester added; the invite goes out when a build in Friends passes Beta App Review (re-run with --invite then)');
expect(s.asc.all('betaTesters')).toHaveLength(1);
expect(invites()).toBe(0);
expect(writes(s.calls).some(c => c.path === '/v1/betaTesterInvitations')).toBe(false);
});
test('once a build passed Beta App Review, --invite sends the invitation', async () => {
group();
const p = project(cfg, CSV);
await capture(() => appleTestflight(p.ctx(['testers', '--group', 'Friends', '--invite', '--yes'])));
expect(invites()).toBe(0);
buildIn('BETA_APPROVED');
const r = await capture(() => appleTestflight(p.ctx(['testers', '--group', 'Friends', '--invite', '--yes'])));
expect(r.exit).toBeNull();
expect(invites()).toBe(1);
});
test('if the 409 still comes back (the build looked installable), it is the same case: warning, no throw, exit 0', async () => {
group();
buildIn('BETA_APPROVED');
const p = project(cfg, CSV);
// Apple's answer wins over our read: the double refuses the invitation as the live API did
s.faults.push({ method: 'POST', match: /\/v1\/betaTesterInvitations/, when: 'before', status: 409, code: 'STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS', detail: 'The tester cannot be invited because the groups they belong to have no installable builds.' });
const r = await capture(() => appleTestflight(p.ctx(['testers', '--group', 'Friends', '--invite', '--yes'])));
expect(r.exit).toBeNull();
expect(r.err).toContain('passes Beta App Review');
expect(s.asc.all('betaTesters')).toHaveLength(1);
});
test('distribute --submit-review prints the hint for testers that are added but not invited', async () => {
group();
buildIn('READY_FOR_BETA_SUBMISSION');
const p = project(cfg, CSV);
await capture(() => appleTestflight(p.ctx(['testers', '--group', 'Friends', '--yes'])));
s.asc.all('buildBetaDetails')[0]!.attributes.processingState = 'x';
s.asc.put({ type: 'preReleaseVersions', id: 'pre-1', attributes: { version: '1.0.0', platform: 'IOS' }, rels: { app: APP } });
s.asc.all('builds')[0]!.rels.preReleaseVersion = 'pre-1';
s.asc.all('builds')[0]!.rels.buildBetaDetail = 'bd-1';
const r = await capture(() => appleTestflight(p.ctx(['distribute', '--build', '7', '--group', 'Friends', '--submit-review', '--yes'])));
expect(r.out + r.err).toContain('tester added; the invite goes out when a build in Friends passes Beta App Review');
});
});

View file

@ -76,6 +76,10 @@ const LISTS: Record<string, { type: string; one?: boolean }> = {
'apps/promotedPurchases': { type: 'promotedPurchases' },
'subscriptions/winBackOffers': { type: 'winBackOffers' },
'betaGroups/betaTesters': { type: 'betaTesters' },
'betaGroups/builds': { type: 'builds' },
'builds/buildBetaDetail': { type: 'buildBetaDetails', one: true },
'builds/betaAppReviewSubmission': { type: 'betaAppReviewSubmissions', one: true },
'buildBundles/buildBundleFileSizes': { type: 'buildBundleFileSizes' },
'apps/analyticsReportRequests': { type: 'analyticsReportRequests' },
'apps/assetLibrary': { type: 'appAssetLibraries', one: true },
'appAssetLibraries/images': { type: 'appAssetLibraryImages' },
@ -162,6 +166,21 @@ export class FakeAsc {
return { type: r.type, id: r.id, attributes: r.attributes, relationships };
}
/** Whether a group of the tester has a build its testers can install (external: BETA_APPROVED / IN_BETA_TESTING; internal: READY_FOR_BETA_TESTING / IN_BETA_TESTING). */
hasInstallableBuild(testerId: string): boolean {
const gids = [this.res.get(testerId)?.rels.betaGroups ?? []].flat();
return gids.some(gid => {
const g = this.res.get(gid);
if (!g) return false;
const internal = g.attributes.isInternalGroup === true;
const builds = internal && g.attributes.hasAccessToAllBuilds === true ? this.all('builds') : this.children(gid, 'builds');
return builds.some(b => {
const st = String(this.children(b.id, 'buildBetaDetails')[0]?.attributes[internal ? 'internalBuildState' : 'externalBuildState'] ?? '');
return internal ? ['READY_FOR_BETA_TESTING', 'IN_BETA_TESTING'].includes(st) : ['BETA_APPROVED', 'IN_BETA_TESTING'].includes(st);
});
});
}
children(parent: string, type: string): R[] {
return this.all(type).filter(r => Object.values(r.rels).some(v => (Array.isArray(v) ? v.includes(parent) : v === parent)));
}
@ -205,6 +224,11 @@ export class FakeAsc {
if (v && v.attributes.state === 'DEVELOPER_REJECTED' && v.type !== 'appStoreVersions') v.attributes.state = 'READY_FOR_REVIEW';
main.attributes.state ??= 'READY_FOR_REVIEW';
}
// seen live: inviting a tester whose groups have no installable build (an external build is installable after Beta App
// Review) is 409 STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS
if (d.type === 'betaTesterInvitations' && !this.hasInstallableBuild(String(main.rels.betaTester)))
return json({ errors: [{ id: '00000000-0000-0000-0000-000000000000', status: '409', code: 'STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS', title: 'There are no installable builds',
detail: 'The tester cannot be invited because the groups they belong to have no installable builds.' }] }, 409);
const u = UNIQUE[d.type];
if (u && this.all(d.type).some(r => u(r) === u(main as R)))
return json({ errors: [{ status: '409', code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE', title: 'The provided entity includes an attribute with a value that has already been used', detail: 'already exists' }] }, 409);
@ -267,7 +291,8 @@ export class FakeAsc {
}
if (!id) return json({ data: this.visible(this.filtered(this.all(type!), u.searchParams)).map(r => this.view(r)), links: {} });
const self = this.res.get(id);
if (!rel) return self && self.type === type ? json({ data: this.view(self) }) : json({ errors: [{ status: '404' }] }, 404);
// include=<relationship>: the children of that type (a build's buildBundles)
if (!rel) return self && self.type === type ? json({ data: this.view(self), included: (u.searchParams.get('include') ?? '').split(',').filter(Boolean).flatMap(t => this.children(self.id, t).map(r => this.view(r))) }) : json({ errors: [{ status: '404' }] }, 404);
if (rel === 'relationships') return json({ data: this.children(id, rel2!).map(r => ({ type: r.type, id: r.id })) });
// an availability's territories are the ids its create named (a to-many relationship, not child resources)
if (rel === 'availableTerritories' && self) {

View file

@ -361,6 +361,8 @@ describe('App Group association under automatic signing (fake App Store Connect)
{ match: new RegExp(`^/v1/bundleIds/${MAIN}/profiles`), answer: () => page(o.main ?? [managed('com.example.notes', GROUP)]) },
{ match: /^\/v1\/profiles\/MANAGED_[^/]+\/relationships\/certificates/, answer: { errors: [{ status: '404' }] }, status: 404 },
{ match: /^\/v1\/apps\/6400000101\/gameCenterDetail/, answer: { errors: [{ status: '404' }] }, status: 404 },
// apple build compares the live version's device families first: an app without versions has none
{ match: /^\/v1\/apps\/6400000101\/appStoreVersions/, answer: page([]) },
] as never;
}
const ensure = async (argv: string[] = ['ensure', '--yes']) => {