storebound/cli/lib/testflight.ts
CRT 3f12452e3f TestFlight through the App Store Connect API: apple testflight status | groups | testers | what-to-test | info | distribute
Beta groups from apple.testflight.groups (created when missing, never changed), testers from a CSV in .storebound/
(masked in logs, confirmation before Apple emails invitations, --remove / --invite), What to Test per locale from the
App Store listing (whatToTest or whatsNew), Test Information and Beta App Review details, builds by exact number to
groups, Beta App Review submission behind --submit-review and a confirmation. Pure planners in cli/lib/testflight.ts,
shared CSV / table / JSON:API helpers. MCP: apple_testflight_status, _groups, _what_to_test, _info, _distribute
(internal groups only). Skill testflight.
2026-10-10 23:28:10 +08:00

297 lines
19 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): string[] {
if (!t) return [];
const e: string[] = [];
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) } } } });
/** POST /v1/betaGroups/{id}/relationships/builds for every group that does not have the build yet. */
export function planDistribute(buildId: string, groups: Group[], buildGroupIds: string[]): Req[] {
return groups.filter(g => !buildGroupIds.includes(g.id) && !(g.internal && g.hasAccessToAllBuilds)).map(g => ({ method: 'POST', path: `/v1/betaGroups/${g.id}/relationships/builds`,
body: many('builds', [buildId]), note: `build -> ${g.internal ? 'internal' : 'external'} group "${g.name}"` }));
}
/**
* "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;
const d = typeof t.description === 'string' ? t.description : t.description[l.id];
if (d) out[l.apple] = d;
}
return out;
}