diff --git a/CHANGELOG.md b/CHANGELOG.md index 4da74d3..8afab1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/cli/apple/testflight.ts b/cli/apple/testflight.ts index 5a5b330..63c272c 100644 --- a/cli/apple/testflight.ts +++ b/cli/apple/testflight.ts @@ -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 { 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 { + 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({ 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, 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 { return (await c.all(`/v1/apps/${appId}/betaGroups?limit=200`)).map(groupFromApi); } @@ -154,16 +167,7 @@ export async function run(ctx: Ctx): Promise { } 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({ 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, 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 { 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 [,]: 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//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')); diff --git a/skills/testflight/SKILL.md b/skills/testflight/SKILL.md index a720a5c..42aadae 100644 --- a/skills/testflight/SKILL.md +++ b/skills/testflight/SKILL.md @@ -64,8 +64,9 @@ beta app localization before a Beta App Review submission, and fields cannot be distribute reports "already available to (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`, diff --git a/tests/idempotency.test.ts b/tests/idempotency.test.ts index 670f19e..26d6ae3 100644 --- a/tests/idempotency.test.ts +++ b/tests/idempotency.test.ts @@ -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"');