storebound/cli/lib/testflight.ts

347 lines
23 KiB
TypeScript

// TestFlight through the App Store Connect API, as pure planners: what exists (read with GETs) + what the config / CSV
// wants -> the requests to send. The command (cli/apple/testflight.ts) reads, plans, prints the plan on --dry and sends.
// API references (checked October 2026):
// beta groups https://developer.apple.com/documentation/appstoreconnectapi/beta-groups
// beta testers https://developer.apple.com/documentation/appstoreconnectapi/beta-testers
// invitations https://developer.apple.com/documentation/appstoreconnectapi/beta-tester-invitations
// What to Test https://developer.apple.com/documentation/appstoreconnectapi/beta-build-localizations
// beta review https://developer.apple.com/documentation/appstoreconnectapi/beta-app-review-submissions
// test information https://developer.apple.com/documentation/appstoreconnectapi/beta-app-localizations
// https://developer.apple.com/documentation/appstoreconnectapi/beta-app-review-details
import { type Req, many, one } from '../apple/jsonapi';
import { parseCsvRecords } from './csv';
import { len } from './copy';
/** "What to Test" per build and locale (betaBuildLocalizations.whatsNew). Apple's API docs state no maximum; 4000 is
* assumed (the App Store "What's New" limit). NOT VERIFIED: keep it well below, testers read it on a phone. */
export const WHAT_TO_TEST_LIMIT = 4000;
/** Beta App Review notes (betaAppReviewDetails.notes): "Review notes have a maximum of 4,000 characters." */
export const BETA_NOTES_LIMIT = 4000;
/** Beta App Description (betaAppLocalizations.description) shown to external testers. */
export const BETA_DESCRIPTION_LIMIT = 4000;
export interface TfGroupSpec {
name: string;
/** Internal group (App Store Connect users of your team; no beta review). Default false = external. */
internal?: boolean;
/** Internal groups only: every new build is available to the group automatically. */
allBuilds?: boolean;
/** External groups only: public TestFlight link (anyone with the link can join, up to publicLinkLimit). */
publicLink?: boolean;
publicLinkLimit?: number;
/** Testers can send feedback (screenshots, comments) from TestFlight. Default true. */
feedback?: boolean;
}
export interface TfConfig {
groups?: TfGroupSpec[];
/** CSV of testers: email, first_name, last_name, groups (";"-separated group names). Must live in .storebound/
* (gitignored): tester emails are personal data and never go into the repo. Default .storebound/testers.csv. */
testers?: string;
/** Beta App Description per language id (or one text for every locale); shown to external testers. */
description?: string | Record<string, string>;
/** Notes for Beta App Review (where to find what, test account hints). The contact comes from
* .storebound/credentials.json reviewContact like for App Review. */
reviewNotes?: string;
}
export interface Group { id: string; name: string; internal: boolean; publicLinkEnabled?: boolean; publicLink?: string | null; hasAccessToAllBuilds?: boolean }
export const groupFromApi = (g: { id: string; attributes: Record<string, unknown> }): Group => ({
id: g.id, name: String(g.attributes.name ?? ''), internal: g.attributes.isInternalGroup === true,
publicLinkEnabled: g.attributes.publicLinkEnabled as boolean | undefined, publicLink: (g.attributes.publicLink as string | null | undefined) ?? null,
hasAccessToAllBuilds: g.attributes.hasAccessToAllBuilds as boolean | undefined,
});
/** Problems with the testflight config. Pure. */
export function validateTestflight(t: TfConfig | undefined, langs: { id: string; apple?: string }[] = []): string[] {
if (!t) return [];
const e: string[] = [];
if (t.description && typeof t.description === 'object' && langs.length) {
const known = new Set(langs.flatMap(l => [l.id, ...(l.apple ? [l.apple] : [])]));
const bad = Object.keys(t.description).filter(k => !known.has(k));
if (bad.length) e.push(`apple.testflight.description: ${bad.map(k => `'${k}'`).join(', ')} is not a language of the config (use a language id or its App Store locale: ${langs.filter(l => l.apple).map(l => `${l.id} / ${l.apple}`).join(', ')})`);
}
const names = new Set<string>();
for (const [i, g] of (t.groups ?? []).entries()) {
const at = `apple.testflight.groups[${i}]`;
if (!g.name || len(g.name) > 50) e.push(`${at}.name: 1-50 characters`);
if (names.has(g.name)) e.push(`${at}.name: duplicate "${g.name}"`);
names.add(g.name);
if (g.internal && (g.publicLink || g.publicLinkLimit !== undefined)) e.push(`${at}: public links are for external groups only`);
if (!g.internal && g.allBuilds !== undefined) e.push(`${at}.allBuilds: internal groups only (external builds go through beta review one by one)`);
if (g.publicLinkLimit !== undefined && !(Number.isInteger(g.publicLinkLimit) && g.publicLinkLimit >= 1 && g.publicLinkLimit <= 10000)) e.push(`${at}.publicLinkLimit: 1-10000`);
}
if (t.testers !== undefined && !/^\.storebound\//.test(t.testers.replace(/^\.\//, '')))
e.push('apple.testflight.testers: keep the tester CSV in .storebound/ (gitignored); emails never go into the repo');
return e;
}
/** POST /v1/betaGroups for every configured group missing on App Store Connect (matched by name). Existing groups are
* never renamed, changed or deleted. */
export function planGroups(appId: string, existing: Group[], want: TfGroupSpec[]): { create: Req[]; existing: string[] } {
const have = new Set(existing.map(g => g.name));
const create: Req[] = want.filter(g => !have.has(g.name)).map(g => ({
method: 'POST', path: '/v1/betaGroups', note: `${g.internal ? 'internal' : 'external'} group "${g.name}"`,
body: { data: { type: 'betaGroups', attributes: {
name: g.name,
...(g.internal ? { isInternalGroup: true, hasAccessToAllBuilds: g.allBuilds ?? false } : {}),
...(!g.internal && g.publicLink ? { publicLinkEnabled: true, publicLinkLimitEnabled: g.publicLinkLimit !== undefined, ...(g.publicLinkLimit !== undefined ? { publicLinkLimit: g.publicLinkLimit } : {}) } : {}),
feedbackEnabled: g.feedback ?? true,
}, relationships: { app: one('apps', appId) } } },
}));
return { create, existing: want.filter(g => have.has(g.name)).map(g => g.name) };
}
export interface Tester { email: string; firstName?: string; lastName?: string; groups: string[] }
const EMAIL = /^[^@\s,;]+@[^@\s,;]+\.[^@\s,;]+$/;
/** Tester CSV -> testers. Columns (header row, any order): email, first_name (or firstname / first), last_name,
* groups (";"-separated group names; empty = `defaultGroup`). Throws on bad emails or duplicates. */
export function parseTesters(csv: string, defaultGroup?: string): Tester[] {
const out: Tester[] = [];
const seen = new Set<string>();
const errors: string[] = [];
for (const [i, r] of parseCsvRecords(csv).entries()) {
const email = (r.email ?? '').toLowerCase();
if (!EMAIL.test(email)) { errors.push(`row ${i + 2}: "${maskEmail(email)}" is not an email`); continue; }
if (seen.has(email)) { errors.push(`row ${i + 2}: ${maskEmail(email)} listed twice`); continue; }
seen.add(email);
const groups = (r.groups ?? r.group ?? '').split(';').map(s => s.trim()).filter(Boolean);
if (!groups.length && defaultGroup) groups.push(defaultGroup);
out.push({ email, firstName: r.first_name || r.firstname || r.first || undefined, lastName: r.last_name || r.lastname || r.last || undefined, groups });
}
if (errors.length) throw new Error(`testers CSV: ${errors.join('; ')}`);
return out;
}
/** "jane.doe@example.com" -> "j***@example.com": emails in logs and MCP output stay readable but not harvestable. */
export function maskEmail(e: string): string {
const at = e.indexOf('@');
if (at < 1) return '***';
return `${e[0]}***${e.slice(at)}`;
}
export interface TesterPlan {
/** New testers: POST /v1/betaTesters with their groups (Apple emails the invitation once a build is available). */
create: Req[];
/** Existing testers added to more groups: POST /v1/betaGroups/{id}/relationships/betaTesters. */
add: Req[];
/** Testers in a managed group but not in the CSV (only with remove): DELETE .../relationships/betaTesters. */
remove: Req[];
unknownGroups: string[];
summary: string[];
}
/**
* Testers of the managed groups (those named in the CSV or `only`) vs the CSV. `existing` maps a beta tester (by
* lower-case email) to its id and current group ids. Removal only with `remove` (it ends their access to the builds
* of that group; the tester record itself is kept).
*/
export function planTesters(groups: Group[], existing: Map<string, { id: string; groupIds: string[] }>, want: Tester[], o: { remove?: boolean; only?: string[] } = {}): TesterPlan {
const byName = new Map(groups.map(g => [g.name, g] as const));
const plan: TesterPlan = { create: [], add: [], remove: [], unknownGroups: [], summary: [] };
const managed = new Set(o.only?.length ? o.only : want.flatMap(t => t.groups));
for (const n of managed) if (!byName.has(n)) plan.unknownGroups.push(n);
const addTo = new Map<string, string[]>();
for (const t of want) {
const target = t.groups.filter(n => managed.has(n) && byName.has(n)).map(n => byName.get(n)!);
if (!target.length) continue;
const cur = existing.get(t.email);
if (!cur) {
plan.create.push({ method: 'POST', path: '/v1/betaTesters', note: `${maskEmail(t.email)} -> ${target.map(g => g.name).join(', ')}`,
body: { data: { type: 'betaTesters', attributes: { email: t.email, ...(t.firstName ? { firstName: t.firstName } : {}), ...(t.lastName ? { lastName: t.lastName } : {}) },
relationships: { betaGroups: many('betaGroups', target.map(g => g.id)) } } } });
continue;
}
for (const g of target) if (!cur.groupIds.includes(g.id)) addTo.set(g.id, [...(addTo.get(g.id) ?? []), cur.id]);
}
for (const [gid, ids] of addTo) {
const g = groups.find(x => x.id === gid)!;
plan.add.push({ method: 'POST', path: `/v1/betaGroups/${gid}/relationships/betaTesters`, body: many('betaTesters', ids), note: `${ids.length} existing tester(s) -> "${g.name}"` });
}
if (o.remove) {
const wanted = new Map<string, Set<string>>();
for (const t of want) for (const n of t.groups) if (byName.has(n)) wanted.set(byName.get(n)!.id, (wanted.get(byName.get(n)!.id) ?? new Set()).add(t.email));
const drop = new Map<string, string[]>();
for (const [email, cur] of existing) for (const gid of cur.groupIds) {
const g = groups.find(x => x.id === gid);
if (!g || !managed.has(g.name)) continue;
if (!wanted.get(gid)?.has(email)) drop.set(gid, [...(drop.get(gid) ?? []), cur.id]);
}
for (const [gid, ids] of drop) {
const g = groups.find(x => x.id === gid)!;
plan.remove.push({ method: 'DELETE', path: `/v1/betaGroups/${gid}/relationships/betaTesters`, body: many('betaTesters', ids), note: `${ids.length} tester(s) leave "${g.name}"` });
}
}
plan.summary.push(`${plan.create.length} new tester(s)`, `${plan.add.reduce((n, r) => n + (r.body as { data: unknown[] }).data.length, 0)} added to groups`,
`${plan.remove.reduce((n, r) => n + (r.body as { data: unknown[] }).data.length, 0)} removed from groups`);
return plan;
}
/** POST /v1/betaTesterInvitations: (re)send the TestFlight invitation email of one tester for this app. */
export const inviteReq = (appId: string, testerId: string, label: string): Req => ({ method: 'POST', path: '/v1/betaTesterInvitations', note: `invite ${label}`,
body: { data: { type: 'betaTesterInvitations', relationships: { app: one('apps', appId), betaTester: one('betaTesters', testerId) } } } });
/** An internal group with hasAccessToAllBuilds gets every build by itself; App Store Connect refuses adding one by hand. */
export const getsAllBuilds = (g: Group): boolean => g.internal && g.hasAccessToAllBuilds === true;
/**
* POST /v1/betaGroups/{id}/relationships/builds for every group that does not have the build yet. Internal groups with
* all builds are skipped (they have it already). `buildGroupIds`: the groups that list the build, read per group with
* GET /v1/betaGroups/{id}/relationships/builds (App Store Connect answers 400 to GET /v1/betaGroups?filter[builds]=,
* and the spec has no GET of a build's groups).
*/
export function planDistribute(buildId: string, groups: Group[], buildGroupIds: string[]): Req[] {
return groups.filter(g => !buildGroupIds.includes(g.id) && !getsAllBuilds(g)).map(g => ({ method: 'POST', path: `/v1/betaGroups/${g.id}/relationships/builds`,
body: many('builds', [buildId]), note: `build -> ${g.internal ? 'internal' : 'external'} group "${g.name}"` }));
}
/** Why group names given to distribute / testers are not on App Store Connect: configured but not created yet (with the
* command that creates them), or unknown (with the groups that do exist). Pure. */
export function unknownGroupMessage(names: string[], existing: Group[], configured: TfGroupSpec[]): string {
const sync = '`storebound apple testflight groups sync`';
const quote = (ns: string[]) => ns.map(n => `"${n}"`).join(', ');
const inConfig = names.filter(n => configured.some(g => g.name === n));
const other = names.filter(n => !inConfig.includes(n));
const lines: string[] = [];
if (inConfig.length) lines.push(inConfig.length > 1
? `groups ${quote(inConfig)} are in the config but not on App Store Connect yet: create them with ${sync}`
: `group ${quote(inConfig)} is in the config but not on App Store Connect yet: create it with ${sync}`);
if (other.length) lines.push(`unknown group${other.length > 1 ? 's' : ''} ${quote(other)}: ${existing.length
? `App Store Connect has ${quote(existing.map(g => g.name))}`
: `App Store Connect has no beta groups yet (add them to apple.testflight.groups, then ${sync})`}`);
return lines.join('; ');
}
/**
* "What to Test" texts for TestFlight per App Store locale from the App Store listing file: a `### whatToTest` block
* when a locale section has one, else its `### whatsNew` block (what `storebound whats-new` drafts). Pure.
*/
export function whatToTestFromListing(md: string): Record<string, string> {
const out: Record<string, string> = {};
for (const sec of md.split(/^## /m).slice(1)) {
const locale = sec.split(/[\s·]/)[0]!.trim();
if (!/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(locale)) continue;
const block = (name: string) => new RegExp(`^### ${name}[^\\n]*\\n\`\`\`[^\\n]*\\n([\\s\\S]*?)\\n\`\`\``, 'm').exec(sec)?.[1]?.trim();
const text = block('whatToTest') ?? block('whatsNew');
if (text) out[locale] = text;
}
return out;
}
/** betaBuildLocalizations to create (POST) or update (PATCH) so the build has exactly these texts. Locales on the
* build that are not in `want` are left alone. Throws when a text is over the limit. */
export function planWhatToTest(buildId: string, existing: { id: string; locale: string; whatsNew?: string | null }[], want: Record<string, string>): Req[] {
const tooLong = Object.entries(want).filter(([, t]) => len(t) > WHAT_TO_TEST_LIMIT).map(([l, t]) => `${l} ${len(t)}`);
if (tooLong.length) throw new Error(`What to Test over ${WHAT_TO_TEST_LIMIT} characters: ${tooLong.join(', ')}`);
const have = new Map(existing.map(l => [l.locale, l] as const));
const out: Req[] = [];
for (const [locale, whatsNew] of Object.entries(want)) {
const cur = have.get(locale);
if (!cur) out.push({ method: 'POST', path: '/v1/betaBuildLocalizations', note: locale,
body: { data: { type: 'betaBuildLocalizations', attributes: { locale, whatsNew }, relationships: { build: one('builds', buildId) } } } });
else if ((cur.whatsNew ?? '') !== whatsNew) out.push({ method: 'PATCH', path: `/v1/betaBuildLocalizations/${cur.id}`, note: locale,
body: { data: { type: 'betaBuildLocalizations', id: cur.id, attributes: { whatsNew } } } });
}
return out;
}
/** POST /v1/betaAppReviewSubmissions: send one build to Beta App Review (needed once per version for external
* testing; later builds of the same version are often approved automatically). */
export const betaReviewReq = (buildId: string): Req => ({ method: 'POST', path: '/v1/betaAppReviewSubmissions', note: 'submit for Beta App Review',
body: { data: { type: 'betaAppReviewSubmissions', relationships: { build: one('builds', buildId) } } } });
/** Build states in which a build can be tested / sent to beta review. */
export function betaReadiness(b: { processingState?: string; expired?: boolean; usesNonExemptEncryption?: boolean | null }, detail: { externalBuildState?: string; internalBuildState?: string } | null): string[] {
const p: string[] = [];
if (b.processingState !== 'VALID') p.push(`processing state ${b.processingState ?? 'unknown'} (wait for VALID)`);
if (b.expired) p.push('expired (TestFlight builds expire after 90 days)');
if (b.usesNonExemptEncryption === null || b.usesNonExemptEncryption === undefined) {
if (detail?.internalBuildState === 'MISSING_EXPORT_COMPLIANCE' || detail?.externalBuildState === 'MISSING_EXPORT_COMPLIANCE')
p.push('export compliance missing (storebound apple encryption --build N)');
}
return p;
}
export interface BetaInfo { description?: string; feedbackEmail?: string; privacyPolicyUrl?: string; marketingUrl?: string }
/** Test Information per locale (betaAppLocalizations): POST missing locales, PATCH changed fields. Apple requires a
* description on every beta app localization before a build can go to Beta App Review; fields cannot be cleared. */
export function planBetaInfo(appId: string, existing: { id: string; locale: string; attributes: BetaInfo }[], want: Record<string, BetaInfo>): Req[] {
const have = new Map(existing.map(l => [l.locale, l] as const));
const out: Req[] = [];
for (const [locale, w] of Object.entries(want)) {
const attrs = Object.fromEntries(Object.entries(w).filter(([, v]) => v !== undefined && v !== '')) as BetaInfo;
if (attrs.description && len(attrs.description) > BETA_DESCRIPTION_LIMIT) throw new Error(`beta description ${locale}: over ${BETA_DESCRIPTION_LIMIT} characters`);
const cur = have.get(locale);
if (!cur) { out.push({ method: 'POST', path: '/v1/betaAppLocalizations', note: locale, body: { data: { type: 'betaAppLocalizations', attributes: { locale, ...attrs }, relationships: { app: one('apps', appId) } } } }); continue; }
const changed = Object.fromEntries(Object.entries(attrs).filter(([k, v]) => (cur.attributes as Record<string, unknown>)[k] !== v));
if (Object.keys(changed).length) out.push({ method: 'PATCH', path: `/v1/betaAppLocalizations/${cur.id}`, note: locale, body: { data: { type: 'betaAppLocalizations', id: cur.id, attributes: changed } } });
}
return out;
}
export interface ReviewContactLike { firstName: string; lastName: string; phone: string; email: string }
/** Beta App Review contact + notes (PATCH /v1/betaAppReviewDetails/{id}; the record exists for every app). */
export function planBetaReviewDetail(detail: { id: string; attributes: Record<string, unknown> } | null, contact: ReviewContactLike | null, notes?: string): Req[] {
if (!detail) return [];
if (notes && len(notes) > BETA_NOTES_LIMIT) throw new Error(`beta review notes: over ${BETA_NOTES_LIMIT} characters`);
const want: Record<string, unknown> = {
...(contact ? { contactFirstName: contact.firstName, contactLastName: contact.lastName, contactPhone: contact.phone, contactEmail: contact.email } : {}),
...(notes ? { notes } : {}),
};
const changed = Object.fromEntries(Object.entries(want).filter(([k, v]) => v && detail.attributes[k] !== v));
return Object.keys(changed).length ? [{ method: 'PATCH', path: `/v1/betaAppReviewDetails/${detail.id}`, note: 'Beta App Review contact / notes',
body: { data: { type: 'betaAppReviewDetails', id: detail.id, attributes: changed } } }] : [];
}
export interface ApiBuild { id: string; attributes: { version?: string; processingState?: string; expired?: boolean; usesNonExemptEncryption?: boolean | null; uploadedDate?: string }; marketing?: string }
/** The one build with this number (CFBundleVersion), optionally of one marketing version. Never "the latest". */
export function pickBuild(builds: ApiBuild[], number: string, marketing?: string): ApiBuild {
const hits = builds.filter(b => b.attributes.version === number && (!marketing || b.marketing === marketing));
if (!hits.length) throw new Error(`no build ${number}${marketing ? ` of version ${marketing}` : ''} on App Store Connect (still processing? storebound apple status)`);
if (hits.length > 1) throw new Error(`build number ${number} exists for several versions (${hits.map(b => b.marketing ?? '?').join(', ')}): add --version x.y.z`);
return hits[0]!;
}
/** Builds response with include=preReleaseVersion -> builds tagged with their marketing version. */
export function buildsWithVersion(resp: { data?: ApiBuild[]; included?: { type: string; id: string; attributes?: { version?: string } }[] }): ApiBuild[] {
const pre = new Map((resp.included ?? []).filter(x => x.type === 'preReleaseVersions').map(x => [x.id, x.attributes?.version] as const));
return (resp.data ?? []).map(b => ({ ...b, marketing: pre.get((b as unknown as { relationships?: { preReleaseVersion?: { data?: { id: string } } } }).relationships?.preReleaseVersion?.data?.id ?? '') }));
}
/** Beta App Description per App Store locale from the config (a string = every locale). */
export function betaDescriptions(t: TfConfig | undefined, langs: { id: string; apple?: string }[]): Record<string, string> {
if (!t?.description) return {};
const out: Record<string, string> = {};
for (const l of langs) {
if (!l.apple) continue;
// a language id ('en') or its App Store locale ('en-US'): an 'en-US' key was silently skipped (Acme 2026-10-29)
const d = typeof t.description === 'string' ? t.description : (t.description[l.id] ?? t.description[l.apple]);
if (d) out[l.apple] = d;
}
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));