testflight distribute: groups of apple.testflight.groups missing on App Store Connect are created first (only the asked-for ones, never removed; dry run shows it); tests, skill, CHANGELOG

This commit is contained in:
CRTkafa 2026-10-21 05:35:19 +07:00
parent 163e666834
commit aceb994cae
4 changed files with 60 additions and 20 deletions

View file

@ -29,7 +29,7 @@ import {
parseTesters, pickBuild, planBetaInfo, planBetaReviewDetail, planDistribute, planGroups, planTesters, planWhatToTest, unknownGroupMessage, whatToTestFromListing,
} from '../lib/testflight';
import { type Asc, ascClient, type J } from './asc';
import { describeReq, execute } from './jsonapi';
import { describeReq, execute, type Req } from './jsonapi';
import { findOrCreate, getById } from '../lib/upsert';
import * as rt from '../lib/rt';
@ -87,6 +87,19 @@ async function pagesOf(c: Asc, path: string): Promise<J[]> {
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);
}
@ -154,16 +167,7 @@ export async function run(ctx: Ctx): Promise<void> {
}
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<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'}`);
}
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;
}
@ -238,10 +242,24 @@ export async function run(ctx: Ctx): Promise<void> {
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');
const gs = await groups(c, appId);
const missing = names.filter(n => !gs.some(g => g.name === n));
if (missing.length) throw new Error(unknownGroupMessage(missing, gs, tf?.groups ?? []));
const target = names.map(n => gs.find(g => g.name === n)!);
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'));