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