322 lines
21 KiB
TypeScript
322 lines
21 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) } } } });
|
|
|
|
/** 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;
|
|
const d = typeof t.description === 'string' ? t.description : t.description[l.id];
|
|
if (d) out[l.apple] = d;
|
|
}
|
|
return out;
|
|
}
|