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

@ -2,6 +2,12 @@
## Unreleased
- **`apple submit` / `resubmit` switch Game Center on first.** A build with the Game Center entitlement and an app
without a Game Center detail was refused with 409 STATE_ERROR.BUILD_INDICATES_GAME_CENTER_ENABLED; submit,
resubmit and `apple signing ensure` now create `gameCenterDetails` and the version's `gameCenterAppVersions` first
(dry run shows it), and `validate` blocks while either is missing. Pitfall 89.
- **`apple testflight distribute` creates missing configured groups first** (only the asked-for ones, never removes
one); tester invites stay the owner's.
- **Console plans follow the live pages (2026-10-21 run).** Play health: "My app does not have any health features"
(verify accepts it, "Other" stays a fallback); Sign in details: "Is any part of your app restricted?" Yes / No and
"Add details" (old labels kept as `altLabels`); finance: the boxes on step 1, step 2 information only; App Privacy:

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'));

View file

@ -64,8 +64,9 @@ beta app localization before a Beta App Review submission, and fields cannot be
distribute reports "already available to <group> (internal, all builds)" and sends nothing (App Store Connect
refuses adding a build to such a group). Internal groups without all builds get the build like external ones.
A re-run adds nothing (it reads each group's builds first).
- A group in `apple.testflight.groups` that is not on App Store Connect yet: run `storebound apple testflight groups
sync` first (distribute and testers say so); a name in neither lists the groups that exist.
- A group in `apple.testflight.groups` that is not on App Store Connect yet: `distribute` creates it first (the
groups sync for the missing `--group` names only; it never removes a group) and the dry run shows it. Inviting
testers stays the owner's step (`apple testflight testers`). A name in neither lists the groups that exist.
- External: same command with an external group; it asks first. External testers only get the build after **Beta App
Review**: add `--submit-review` (asks again) or submit in App Store Connect. The first build of a version is
reviewed; later builds of the same version are often approved quickly. Beta review states: `WAITING_FOR_REVIEW`,

View file

@ -134,11 +134,26 @@ describe('apple testflight distribute (real use 2026-10-13)', () => {
expect(out.out).toContain('already in Devs (internal)');
});
test('a group in the config but not on App Store Connect points at groups sync; an unknown one lists what exists', async () => {
test('a group in the config but not on App Store Connect is created first (groups sync), then gets the build; the re-run writes nothing', async () => {
const p = project(TF);
s.asc.put({ type: 'builds', id: 'b-1', attributes: { version: '1', processingState: 'VALID', usesNonExemptEncryption: false, expired: false }, rels: { app: APP } });
s.asc.put({ type: 'betaGroups', id: 'grp-devs', attributes: { name: 'Devs', isInternalGroup: true, hasAccessToAllBuilds: false }, rels: { app: APP } });
const dry = await capture(() => appleTestflight(p.ctx(['distribute', '--build', '1', '--group', 'Friends', '--dry'])));
expect(dry.out).toContain('would create beta group(s) from apple.testflight.groups first: "Friends"');
expect(count('betaGroups')).toBe(1);
const r = await twice(() => appleTestflight(p.ctx(['distribute', '--build', '1', '--group', 'Friends,Devs', '--yes'])));
expect(r.first[0]).toBe('POST /v1/betaGroups');
expect(r.first.filter(w => w === 'POST /v1/betaGroups')).toHaveLength(1);
expect(r.first.filter(w => /relationships\/builds$/.test(w))).toHaveLength(2);
expect(r.second).toEqual([]);
// only the missing one of --group was created; Team (configured, not asked for) was not; nothing removed
expect(s.asc.all('betaGroups').map(g => g.attributes.name).sort()).toEqual(['Devs', 'Friends']);
expect(s.calls.some(c => c.method === 'DELETE')).toBe(false);
});
test('an unknown group (not in the config) still lists what exists', async () => {
const p = project(TF);
s.asc.put({ type: 'builds', id: 'b-1', attributes: { version: '1', processingState: 'VALID' }, rels: { app: APP } });
await expect(capture(() => appleTestflight(p.ctx(['distribute', '--build', '1', '--group', 'Team', '--yes']))))
.rejects.toThrow('group "Team" is in the config but not on App Store Connect yet: create it with `storebound apple testflight groups sync`');
s.asc.put({ type: 'betaGroups', id: 'grp-friends', attributes: { name: 'Friends', isInternalGroup: false }, rels: { app: APP } });
await expect(capture(() => appleTestflight(p.ctx(['distribute', '--build', '1', '--group', 'Nope', '--yes']))))
.rejects.toThrow('unknown group "Nope": App Store Connect has "Friends"');