384 lines
29 KiB
TypeScript
384 lines
29 KiB
TypeScript
// TestFlight through the App Store Connect API.
|
|
// storebound apple testflight status [--json] groups, testers per group, recent builds + beta states
|
|
// storebound apple testflight groups list the groups and what the config would create (read-only)
|
|
// storebound apple testflight groups sync [--dry] | groups --yes create the groups of apple.testflight.groups that are missing
|
|
// storebound apple testflight testers [--csv f] [--group a,b] [--remove] [--invite] [--dry] [--yes]
|
|
// storebound apple testflight what-to-test --build N [--version x.y.z] [--locale en-US,tr] [--text "..."] [--dry]
|
|
// storebound apple testflight info [--dry] Test Information + Beta App Review contact / notes
|
|
// storebound apple testflight distribute --build N --group a,b [--version x.y.z] [--internal-only] [--submit-review] [--dry] [--yes]
|
|
// storebound apple testflight wait --build N [--version x.y.z] [--timeout 60m] [--dry] [--json] until processing ends
|
|
// exit codes: 0 VALID, 1 FAILED / INVALID, 7 timeout; --dry checks once and does not wait
|
|
// storebound apple testflight expire (--keep N | --older-than D) [--version x.y.z] [--dry] [--yes] [--json]
|
|
// expires old builds (permanent; asks first); builds attached to an App Store version and processing builds stay
|
|
// Testers come from a CSV in .storebound/ (gitignored; never commit tester emails): email, first_name, last_name,
|
|
// groups (";"-separated). Emails are masked in every log line. Adding testers makes Apple email TestFlight invitations
|
|
// (once the group has a build), so `testers` asks first; `--remove` takes testers out of the managed groups.
|
|
// `distribute --submit-review` sends the build to Beta App Review (external groups) after a confirmation; neither
|
|
// testers nor external distribution nor the review submission are MCP tools. Pure planning: cli/lib/testflight.ts.
|
|
import { elapsed, heartbeat, type HeartbeatOptions } from '../lib/progress';
|
|
import { existsSync, readFileSync } from 'node:fs';
|
|
import type { Ctx } from '../lib/ctx';
|
|
import { confirm, need } from '../lib/ctx';
|
|
import { reviewContact } from '../lib/credentials';
|
|
import { done, log, printJson, warn } from '../lib/log';
|
|
import { textTable } from '../lib/table';
|
|
import { BUILD_WAIT_EXIT, type BuildSeen, expireCandidates, planExpire, validHint, waitDelay, type WaitOutcome, waitOutcome } from '../lib/build-ops';
|
|
import { parseDuration } from '../lib/release';
|
|
import {
|
|
type ApiBuild, betaDescriptions, betaReadiness, betaReviewReq, buildsWithVersion, getsAllBuilds, type Group, groupFromApi, inviteReq, maskEmail,
|
|
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';
|
|
import { findOrCreate, getById } from '../lib/upsert';
|
|
import * as rt from '../lib/rt';
|
|
|
|
const TAG = 'testflight';
|
|
const USAGE = 'usage: storebound apple testflight status | groups [sync] | testers | what-to-test --build N | info | distribute --build N --group <names> | wait --build N | expire --keep N | --older-than D';
|
|
|
|
/** The state of build `number` (of `marketing` version) right now; found: false while App Store Connect does not list it. */
|
|
async function seeBuild(c: Asc, appId: string, number: string, marketing?: string): Promise<BuildSeen & { version?: string }> {
|
|
const q = new URLSearchParams({ 'filter[app]': appId, 'filter[version]': number, 'filter[preReleaseVersion.platform]': 'IOS', include: 'preReleaseVersion,buildBetaDetail', limit: '50' });
|
|
if (marketing) q.set('filter[preReleaseVersion.version]', marketing);
|
|
const resp = await c.asc('GET', `/v1/builds?${q}`);
|
|
const hits = buildsWithVersion(resp).filter(b => b.attributes.version === number && (!marketing || b.marketing === marketing));
|
|
if (!hits.length) return { found: false };
|
|
const b = pickBuild(hits, number, marketing);
|
|
const d = (resp.included ?? []).find((x: J) => x.type === 'buildBetaDetails' && x.id === (b as J).relationships?.buildBetaDetail?.data?.id)?.attributes;
|
|
return { found: true, processingState: b.attributes.processingState, internal: d?.internalBuildState ?? null, external: d?.externalBuildState ?? null, version: b.marketing };
|
|
}
|
|
|
|
export interface BuildWait { outcome: WaitOutcome; timedOut: boolean; seen: BuildSeen & { version?: string }; waitedSeconds: number }
|
|
|
|
/**
|
|
* Polls build `number` until processing ends (VALID, or FAILED / INVALID) or `timeoutMs` passes; logs each state change.
|
|
* `once` reads the state a single time. Shared by `testflight wait` and `apple upload` (which waits after the upload).
|
|
*/
|
|
export async function waitForBuild(c: Asc, appId: string, number: string, o: { marketing?: string; timeoutMs: number; once?: boolean; tag?: string } & HeartbeatOptions): Promise<BuildWait> {
|
|
const started = Date.now();
|
|
let delay: number | null = null, last = '';
|
|
// polls are up to 2 minutes apart: a heartbeat in between says the wait is alive
|
|
const beat = o.once ? null : heartbeat(ms => log(o.tag ?? TAG, `still waiting for build ${number}: ${last || 'first check'}, ${elapsed(ms)} of ${elapsed(o.timeoutMs)}`), o);
|
|
try { for (;;) {
|
|
const seen = await seeBuild(c, appId, number, o.marketing);
|
|
const outcome = waitOutcome(seen);
|
|
const line = outcome === 'missing' ? 'not listed yet (App Store Connect lists it once processing starts)' : `${seen.processingState}${seen.internal ? `, internal ${seen.internal}` : ''}`;
|
|
if (line !== last) { log(o.tag ?? TAG, `build ${number}${seen.version ? ` (${seen.version})` : ''}: ${line} [${Math.round((Date.now() - started) / 60_000)} min]`); last = line; }
|
|
const result = (timedOut: boolean): BuildWait => ({ outcome, timedOut, seen, waitedSeconds: Math.round((Date.now() - started) / 1000) });
|
|
if (outcome === 'valid' || outcome === 'failed') return result(false);
|
|
if (o.once) return result(false);
|
|
delay = waitDelay(delay);
|
|
if (Date.now() + delay - started > o.timeoutMs) return result(true);
|
|
await rt.sleep(delay);
|
|
} } finally { beat?.stop(); }
|
|
}
|
|
|
|
/** The closing line for a finished wait (VALID with the next step, or FAILED / INVALID with what to do). */
|
|
export function waitMessage(number: string, r: BuildWait): string {
|
|
return r.outcome === 'valid'
|
|
? `build ${number} is VALID; ${validHint(r.seen).replace('N', number)}`
|
|
: `build ${number} is ${r.seen.processingState}: read the email from App Store Connect (missing icons, wrong SDK, invalid signature), fix and upload a new build number`;
|
|
}
|
|
|
|
/** Every page of a GET, with `included` kept (c.all keeps only data). */
|
|
async function pagesOf(c: Asc, path: string): Promise<J[]> {
|
|
const out: J[] = [];
|
|
for (let url: string | null = path; url;) { const page: J = await c.asc('GET', url); out.push(page); url = page.links?.next ?? null; }
|
|
return out;
|
|
}
|
|
|
|
/** Creates the planned groups (fresh lookup, then the journal for the list lag, then the create; a 409 finds it). */
|
|
async function createGroups(c: Asc, appId: string, root: string, create: Req[], dry: boolean): Promise<void> {
|
|
if (dry) { await execute(c.asc, create, { dry, tag: TAG }); return; }
|
|
for (const r of create) {
|
|
const name = (r.body as J).data.attributes.name as string;
|
|
const made = await findOrCreate<J>({ what: `beta group "${name}"`, tag: TAG, unique: true,
|
|
find: async () => (await c.all(`/v1/apps/${appId}/betaGroups?limit=200`)).find((g: J) => g.attributes.name === name) ?? null,
|
|
journal: { root, key: `apple:${appId}:betaGroup:${name}`, byId: id => getById(c.asc, `/v1/betaGroups/${id}`) as Promise<J | null>, idOf: v => v.id },
|
|
create: async () => (await c.asc('POST', r.path, r.body)).data });
|
|
log(TAG, `${describeReq(r)}: ${made.created ? 'done' : 'exists already'}`);
|
|
}
|
|
}
|
|
|
|
async function groups(c: Asc, appId: string): Promise<Group[]> {
|
|
return (await c.all(`/v1/apps/${appId}/betaGroups?limit=200`)).map(groupFromApi);
|
|
}
|
|
|
|
async function findBuild(c: Asc, appId: string, number: string, marketing?: string): Promise<ApiBuild & { detail: J }> {
|
|
const q = new URLSearchParams({ 'filter[app]': appId, 'filter[version]': number, 'filter[preReleaseVersion.platform]': 'IOS', include: 'preReleaseVersion,buildBetaDetail', limit: '50' });
|
|
if (marketing) q.set('filter[preReleaseVersion.version]', marketing);
|
|
const resp = await c.asc('GET', `/v1/builds?${q}`);
|
|
const b = pickBuild(buildsWithVersion(resp), number, marketing);
|
|
const detail = (resp.included ?? []).find((x: J) => x.type === 'buildBetaDetails' && x.id === (b as J).relationships?.buildBetaDetail?.data?.id)?.attributes ?? null;
|
|
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;
|
|
const sub = a._[0];
|
|
if (!sub) throw new Error(USAGE);
|
|
const dry = a.has('dry');
|
|
const tf = config.apple?.testflight;
|
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
|
const appId = await c.app();
|
|
|
|
if (sub === 'status') {
|
|
const gs = await groups(c, appId);
|
|
const rows: (string | number)[][] = [];
|
|
const jsonGroups: Record<string, unknown>[] = [];
|
|
for (const g of gs) {
|
|
const testers = await c.all(`/v1/betaGroups/${g.id}/betaTesters?limit=200`);
|
|
const builds = await c.all(`/v1/betaGroups/${g.id}/builds?limit=200`);
|
|
jsonGroups.push({ id: g.id, name: g.name, internal: g.internal, testers: testers.length, builds: g.internal && g.hasAccessToAllBuilds ? 'all' : builds.length, publicLink: !!g.publicLinkEnabled });
|
|
rows.push([g.name, g.internal ? 'internal' : 'external', testers.length, g.internal && g.hasAccessToAllBuilds ? 'all' : builds.length, g.publicLinkEnabled ? 'public link on' : '']);
|
|
}
|
|
const q = new URLSearchParams({ 'filter[app]': appId, include: 'preReleaseVersion,buildBetaDetail', sort: '-uploadedDate', limit: '8' });
|
|
const resp = await c.asc('GET', `/v1/builds?${q}`);
|
|
const details = new Map((resp.included ?? []).filter((x: J) => x.type === 'buildBetaDetails').map((x: J) => [x.id, x.attributes] as const));
|
|
const missingGroups = (tf?.groups ?? []).filter(w => !gs.some(g => g.name === w.name)).map(w => w.name);
|
|
if (a.has('json')) {
|
|
printJson({ groups: jsonGroups, builds: buildsWithVersion(resp).map(b => {
|
|
const d = details.get((b as J).relationships?.buildBetaDetail?.data?.id) as J;
|
|
return { version: b.marketing ?? null, build: b.attributes.version ?? null, processing: b.attributes.processingState ?? null, internal: d?.internalBuildState ?? null, external: d?.externalBuildState ?? null, uploaded: b.attributes.uploadedDate ?? null };
|
|
}), missingGroups });
|
|
return;
|
|
}
|
|
console.log(textTable(['group', 'kind', 'testers', 'builds', ''], rows));
|
|
console.log('\n' + textTable(['version', 'build', 'processing', 'internal', 'external', 'uploaded'], buildsWithVersion(resp).map(b => {
|
|
const d = details.get((b as J).relationships?.buildBetaDetail?.data?.id) as J;
|
|
return [b.marketing ?? '', b.attributes.version ?? '', b.attributes.processingState ?? '', d?.internalBuildState ?? '', d?.externalBuildState ?? '', (b.attributes.uploadedDate ?? '').slice(0, 10)];
|
|
})));
|
|
if (missingGroups.length) warn(TAG, `configured but not on App Store Connect: ${missingGroups.join(', ')} (storebound apple testflight groups sync)`);
|
|
return;
|
|
}
|
|
|
|
if (sub === 'groups') {
|
|
const gs = await groups(c, appId);
|
|
const plan = planGroups(appId, gs, tf?.groups ?? []);
|
|
// bare `groups` only lists: creating is `groups sync` (or `groups --yes`); a listing-sounding command never writes
|
|
const write = a._[1] === 'sync' || a.has('yes');
|
|
if (!write) {
|
|
for (const g of gs) log(TAG, `group "${g.name}": ${g.internal ? 'internal' : 'external'}${tf?.groups?.some(w => w.name === g.name) ? '' : ' (not in apple.testflight.groups)'}`);
|
|
if (!gs.length) log(TAG, 'no beta groups on App Store Connect yet');
|
|
const names = plan.create.map(r => `"${(r.body as J).data.attributes.name}"`);
|
|
done(TAG, names.length ? `would create ${names.join(', ')} (from apple.testflight.groups): storebound apple testflight groups sync` : 'every configured group exists');
|
|
return;
|
|
}
|
|
if (!tf?.groups?.length) warn(TAG, 'apple.testflight.groups is empty: nothing to create');
|
|
for (const n of plan.existing) log(TAG, `group "${n}": exists`);
|
|
await createGroups(c, appId, root, plan.create, dry);
|
|
done(TAG, dry ? `groups checked (dry run): ${plan.create.length} to create` : `groups done (${plan.create.length} created)`);
|
|
return;
|
|
}
|
|
|
|
if (sub === 'testers') {
|
|
const csvPath = resolve(a.get('csv') ?? tf?.testers ?? '.storebound/testers.csv');
|
|
if (!existsSync(csvPath)) throw new Error(`no tester CSV at ${csvPath} (columns: email, first_name, last_name, groups; keep it in .storebound/)`);
|
|
const only = a.list('group');
|
|
const want = parseTesters(readFileSync(csvPath, 'utf8'), only.length === 1 ? only[0] : undefined);
|
|
const gs = await groups(c, appId);
|
|
const existing = new Map<string, { id: string; groupIds: string[]; state?: string }>();
|
|
const resp = await c.all(`/v1/betaTesters?filter[apps]=${appId}&include=betaGroups&limit=200`);
|
|
for (const t of resp) existing.set(String(t.attributes.email ?? '').toLowerCase(), { id: t.id, groupIds: (t.relationships?.betaGroups?.data ?? []).map((g: J) => g.id), state: t.attributes.state });
|
|
const plan = planTesters(gs, existing, want, { remove: a.has('remove'), only });
|
|
if (plan.unknownGroups.length) throw new Error(unknownGroupMessage(plan.unknownGroups, gs, tf?.groups ?? []));
|
|
const internal = gs.filter(g => g.internal && want.some(t => t.groups.includes(g.name)));
|
|
if (internal.length) warn(TAG, `internal groups (${internal.map(g => g.name).join(', ')}) only take App Store Connect users of your team (Users and Access)`);
|
|
log(TAG, `${want.length} testers in the CSV: ${plan.summary.join(', ')}`);
|
|
const reqs = [...plan.create, ...plan.add, ...plan.remove];
|
|
if (!reqs.length && !a.has('invite')) { done(TAG, 'testers up to date'); return; }
|
|
if (dry) { await execute(c.asc, reqs, { dry, tag: TAG }); done(TAG, 'testers checked (dry run)'); return; }
|
|
if (reqs.length && !(await confirm(ctx, `Apply ${plan.summary.join(', ')}? New testers get a TestFlight invitation email from Apple.`))) return;
|
|
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));
|
|
// 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;
|
|
}
|
|
|
|
if (sub === 'what-to-test') {
|
|
const number = need(a.get('build'), '--build N (the exact build number)');
|
|
const b = await findBuild(c, appId, number, a.get('version'));
|
|
let want: Record<string, string>;
|
|
const locales = a.list('locale');
|
|
if (a.get('text')) {
|
|
const ls = locales.length ? locales : [config.apple?.primaryLocale ?? config.languages.find(l => l.apple)?.apple ?? 'en-US'];
|
|
want = Object.fromEntries(ls.map(l => [l, a.get('text')!]));
|
|
} else {
|
|
const f = resolve(config.apple?.listing ?? 'store/appstore.md');
|
|
want = whatToTestFromListing(readFileSync(f, 'utf8'));
|
|
if (locales.length) want = Object.fromEntries(Object.entries(want).filter(([l]) => locales.includes(l)));
|
|
if (!Object.keys(want).length) throw new Error(`${f}: no "### whatToTest" or "### whatsNew" block (storebound whats-new drafts one), or pass --text`);
|
|
}
|
|
const existing = (await c.all(`/v1/builds/${b.id}/betaBuildLocalizations?limit=200`)).map((l: J) => ({ id: l.id, locale: l.attributes.locale, whatsNew: l.attributes.whatsNew }));
|
|
const reqs = planWhatToTest(b.id, existing, want);
|
|
if (!reqs.length) { done(TAG, `build ${number}: What to Test up to date (${Object.keys(want).join(', ')})`); return; }
|
|
await execute(c.asc, reqs, { dry, tag: TAG });
|
|
done(TAG, dry ? `build ${number}: ${reqs.length} locale(s) to set (dry run)` : `build ${number}: What to Test set for ${reqs.length} locale(s)`);
|
|
return;
|
|
}
|
|
|
|
if (sub === 'info') {
|
|
const desc = betaDescriptions(tf, config.languages);
|
|
if (!Object.keys(desc).length) warn(TAG, 'apple.testflight.description is not set: external testing needs a Beta App Description per locale');
|
|
const want = Object.fromEntries(Object.entries(desc).map(([locale, description]) => [locale, { description, feedbackEmail: config.app.contactEmail,
|
|
privacyPolicyUrl: config.app.privacyPolicyUrl, marketingUrl: config.app.marketingUrl ?? config.app.website }]));
|
|
const locs = (await c.all(`/v1/apps/${appId}/betaAppLocalizations?limit=200`)).map((l: J) => ({ id: l.id, locale: l.attributes.locale, attributes: l.attributes }));
|
|
const detail = (await c.maybe(`/v1/apps/${appId}/betaAppReviewDetail`))?.data ?? null;
|
|
const contact = reviewContact(root);
|
|
if (!contact) warn(TAG, 'no reviewContact in .storebound/credentials.json: Beta App Review contact left as it is');
|
|
const reqs = [...planBetaInfo(appId, locs, want), ...planBetaReviewDetail(detail, contact, tf?.reviewNotes ?? config.apple?.info?.reviewNotes)];
|
|
await execute(c.asc, reqs, { dry, tag: TAG });
|
|
done(TAG, reqs.length ? (dry ? `${reqs.length} change(s) (dry run)` : `test information updated (${reqs.length})`) : 'test information up to date');
|
|
return;
|
|
}
|
|
|
|
if (sub === 'distribute') {
|
|
const number = need(a.get('build'), '--build N (the exact build number you uploaded)');
|
|
const names = a.list('group');
|
|
if (!names.length) throw new Error('--group <name>[,<name>]: which beta groups get the build');
|
|
let gs = await groups(c, appId);
|
|
// groups the config names but App Store Connect does not have yet: the groups sync first (create only the missing
|
|
// configured ones of --group, never remove any); a name the config does not know stays an error
|
|
const absent = names.filter(n => !gs.some(g => g.name === n));
|
|
const configured = (tf?.groups ?? []).filter(g => absent.includes(g.name));
|
|
const unknown = absent.filter(n => !configured.some(g => g.name === n));
|
|
if (unknown.length) throw new Error(unknownGroupMessage(unknown, gs, tf?.groups ?? []));
|
|
if (configured.length) {
|
|
const sync = planGroups(appId, gs, configured);
|
|
log(TAG, `${dry ? 'would create' : 'creating'} beta group(s) from apple.testflight.groups first: ${configured.map(g => `"${g.name}"`).join(', ')} (testers stay the owner's: apple testflight testers)`);
|
|
await createGroups(c, appId, root, sync.create, dry);
|
|
if (dry) {
|
|
for (const g of configured) log(TAG, `would POST /v1/betaGroups/<new ${g.name}>/relationships/builds {build ${number}}${g.internal && g.allBuilds ? ' (none: an internal all-builds group gets it by itself)' : ''}`);
|
|
if (configured.length === names.length) { done(TAG, 'distribute checked (dry run)'); return; }
|
|
} else gs = await groups(c, appId);
|
|
}
|
|
const target = names.map(n => gs.find(g => g.name === n)).filter((g): g is Group => !!g);
|
|
if (!dry && target.length < names.length) throw new Error(`beta group(s) ${names.filter(n => !target.some(g => g.name === n)).join(', ')} created but not listed yet (App Store Connect lists lag after a create): run distribute again in a minute; nothing is created twice`);
|
|
const external = target.filter(g => !g.internal);
|
|
if (a.has('internal-only') && external.length) throw new Error(`external group(s) ${external.map(g => g.name).join(', ')}: distribute to external testers from the terminal (asks first)`);
|
|
const b = await findBuild(c, appId, number, a.get('version'));
|
|
const problems = betaReadiness(b.attributes, b.detail);
|
|
if (problems.length) throw new Error(`build ${number}: ${problems.join('; ')}`);
|
|
log(TAG, `build ${number} (${b.marketing ?? '?'}): internal ${b.detail?.internalBuildState ?? '?'}, external ${b.detail?.externalBuildState ?? '?'}`);
|
|
// a build's groups, read per group: GET /v1/betaGroups?filter[builds]= is refused live (400) and the spec has no
|
|
// GET /v1/builds/{id}/betaGroups; internal all-builds groups have every build and take none by hand
|
|
const inGroups: string[] = [];
|
|
for (const g of target) {
|
|
if (getsAllBuilds(g)) { log(TAG, `build ${number}: already available to ${g.name} (internal, all builds)`); continue; }
|
|
if ((await c.all(`/v1/betaGroups/${g.id}/relationships/builds?limit=200`)).some((x: J) => x.id === b.id)) {
|
|
inGroups.push(g.id);
|
|
log(TAG, `build ${number}: already in ${g.name} (${g.internal ? 'internal' : 'external'})`);
|
|
}
|
|
}
|
|
const reqs = planDistribute(b.id, target, inGroups);
|
|
const review = a.has('submit-review') && external.length ? (await c.maybe(`/v1/builds/${b.id}/betaAppReviewSubmission`))?.data ?? null : null;
|
|
const wantsReview = a.has('submit-review') && external.length > 0 && !review && !['BETA_APPROVED', 'IN_BETA_REVIEW', 'WAITING_FOR_BETA_REVIEW', 'IN_BETA_TESTING'].includes(b.detail?.externalBuildState);
|
|
if (a.has('submit-review') && review) log(TAG, `beta review: already submitted (${review.attributes?.betaReviewState ?? '?'})`);
|
|
if (external.length && !a.has('submit-review') && b.detail?.externalBuildState === 'READY_FOR_BETA_SUBMISSION')
|
|
warn(TAG, 'external testers only get the build after Beta App Review: add --submit-review (asks first) or submit it in App Store Connect');
|
|
// Beta App Review refuses a build whose app has no beta localization in its primary locale (422
|
|
// MISSING_BETA_APP_LOCALIZATION): said before the submit, in the dry run too
|
|
if (wantsReview) {
|
|
const primary = String((await c.maybe(`/v1/apps/${appId}?fields[apps]=primaryLocale`))?.data?.attributes?.primaryLocale ?? '');
|
|
const have = (await c.all(`/v1/apps/${appId}/betaAppLocalizations?limit=200`)).map((l: J) => String(l.attributes.locale));
|
|
if (primary && !have.includes(primary)) {
|
|
const msg = `Beta App Review needs test information in the app's primary locale ${primary} (has: ${have.join(', ') || 'none'}): add it to apple.testflight.description (key ${primary} or its language id), then storebound apple testflight info`;
|
|
if (dry) warn(TAG, msg); else throw new Error(msg);
|
|
}
|
|
}
|
|
if (dry) {
|
|
await execute(c.asc, [...reqs, ...(wantsReview ? [betaReviewReq(b.id)] : [])], { dry, tag: TAG });
|
|
done(TAG, 'distribute checked (dry run)');
|
|
return;
|
|
}
|
|
if (external.length && reqs.some(r => external.some(g => r.path.includes(g.id))) && !(await confirm(ctx, `Give build ${number} to external group(s) ${external.map(g => g.name).join(', ')}? Testers are notified once Beta App Review approves it.`))) return;
|
|
await execute(c.asc, reqs, { dry, tag: TAG });
|
|
if (wantsReview) {
|
|
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;
|
|
}
|
|
if (sub === 'wait') {
|
|
const number = need(a.get('build'), '--build N (the exact build number you uploaded)');
|
|
const marketing = a.get('version');
|
|
const r = await waitForBuild(c, appId, number, { marketing, timeoutMs: parseDuration(a.str('timeout', '60m')), once: dry });
|
|
const code = r.outcome === 'valid' ? BUILD_WAIT_EXIT.valid : r.outcome === 'failed' ? BUILD_WAIT_EXIT.failed : BUILD_WAIT_EXIT.timeout;
|
|
if (a.has('json')) printJson({ build: number, version: r.seen.version ?? marketing ?? null, outcome: r.timedOut ? 'timeout' : r.outcome, processingState: r.seen.processingState ?? null,
|
|
internal: r.seen.internal ?? null, external: r.seen.external ?? null, waitedSeconds: r.waitedSeconds });
|
|
log(TAG, r.outcome === 'valid' || r.outcome === 'failed' ? waitMessage(number, r) : dry ? 'dry run: checked once, not waiting' : `timeout after ${Math.round(r.waitedSeconds / 60)} min; still ${r.outcome} (re-run to keep waiting)`);
|
|
process.exit(code);
|
|
}
|
|
|
|
if (sub === 'expire') {
|
|
const keep = a.get('keep') === undefined ? undefined : a.num('keep', 0);
|
|
const olderThanDays = a.get('older-than') === undefined ? undefined : a.num('older-than', 0);
|
|
const q = new URLSearchParams({ 'filter[app]': appId, 'filter[expired]': 'false', 'filter[preReleaseVersion.platform]': 'IOS', sort: '-uploadedDate', include: 'preReleaseVersion,appStoreVersion', limit: '200' });
|
|
if (a.get('version')) q.set('filter[preReleaseVersion.version]', a.get('version')!);
|
|
const plan = planExpire(expireCandidates(await pagesOf(c, `/v1/builds?${q}`)), { keep, olderThanDays });
|
|
const row = (b: { build: string; version?: string; uploaded: string }, what: string) => [b.version ?? '', b.build, b.uploaded.slice(0, 10), what];
|
|
const summary = { expire: plan.expire.map(b => ({ build: b.build, version: b.version ?? null, uploaded: b.uploaded })), keep: plan.keep.length,
|
|
protected: plan.protected.map(p => ({ build: p.b.build, version: p.b.version ?? null, why: p.why })) };
|
|
if (a.has('json') && dry) { printJson({ dryRun: true, ...summary }); return; }
|
|
if (!a.has('json')) console.log(textTable(['version', 'build', 'uploaded', ''], [...plan.expire.map(b => row(b, 'expire')), ...plan.protected.map(p => row(p.b, `keep: ${p.why}`)), ...plan.keep.map(b => row(b, 'keep'))]));
|
|
if (!plan.reqs.length) { done(TAG, 'nothing to expire'); if (a.has('json')) printJson({ dryRun: dry, ...summary, expired: [] }); return; }
|
|
if (dry) { await execute(c.asc, plan.reqs, { dry, tag: TAG }); done(TAG, `dry run: ${plan.reqs.length} build(s) would be expired`); return; }
|
|
if (!(await confirm(ctx, `Expire ${plan.reqs.length} build(s) (${plan.expire.map(b => b.build).join(', ')})? Testers lose them; an expired build cannot be brought back.`))) return;
|
|
await execute(c.asc, plan.reqs, { dry: false, tag: TAG });
|
|
if (a.has('json')) printJson({ dryRun: false, ...summary, expired: plan.expire.map(b => b.build) });
|
|
done(TAG, `${plan.reqs.length} build(s) expired`);
|
|
return;
|
|
}
|
|
throw new Error(`unknown testflight command "${sub}"\n${USAGE}`);
|
|
}
|