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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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