// TestFlight through the App Store Connect API. // storebound apple testflight status [--json] groups, testers per group, recent builds + beta states // storebound apple testflight groups [--dry] 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 { 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, type Group, groupFromApi, inviteReq, maskEmail, parseTesters, pickBuild, planBetaInfo, planBetaReviewDetail, planDistribute, planGroups, planTesters, planWhatToTest, whatToTestFromListing, } from '../lib/testflight'; import { type Asc, ascClient, type J } from './asc'; import { describeReq, execute } from './jsonapi'; import { findOrCreate, getById } from '../lib/upsert'; const TAG = 'testflight'; const USAGE = 'usage: storebound apple testflight status | groups | testers | what-to-test --build N | info | distribute --build N --group | 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 { 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 }): Promise { const started = Date.now(); let delay: number | null = null, last = ''; 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 Bun.sleep(delay); } } /** 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 { 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; } async function groups(c: Asc, appId: string): Promise { return (await c.all(`/v1/apps/${appId}/betaGroups?limit=200`)).map(groupFromApi); } async function findBuild(c: Asc, appId: string, number: string, marketing?: string): Promise { 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 }; } export async function run(ctx: Ctx): Promise { 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[] = []; 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)`); return; } if (sub === 'groups') { const plan = planGroups(appId, await groups(c, appId), tf?.groups ?? []); 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`); if (dry) await execute(c.asc, plan.create, { dry, tag: TAG }); else for (const r of plan.create) { // looked up right before the create (fresh list, then the journal for the list lag); a 409 finds it const name = (r.body as J).data.attributes.name as string; const made = await findOrCreate({ 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, idOf: v => v.id }, create: async () => (await c.asc('POST', r.path, r.body)).data }); log(TAG, `${describeReq(r)}: ${made.created ? 'done' : 'exists already'}`); } 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(); 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(`unknown group(s): ${plan.unknownGroups.join(', ')} (storebound apple testflight groups creates the configured ones)`); 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)); 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 }); } 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; 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 [,]: which beta groups get the build'); const gs = await groups(c, appId); const target = names.map(n => gs.find(g => g.name === n) ?? (() => { throw new Error(`unknown group "${n}" (${gs.map(g => g.name).join(', ')})`); })()); 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 ?? '?'}`); const inGroups = (await c.all(`/v1/betaGroups?filter[app]=${appId}&filter[builds]=${b.id}&limit=200`)).map((g: J) => g.id); 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'); 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 }); } done(TAG, `build ${number}: ${reqs.length ? `added to ${reqs.length} group(s)` : 'already in every group'}`); 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}`); }