From 41d2ffcc243fea98b9d2d67a3e917cc48e3af2e2 Mon Sep 17 00:00:00 2001 From: CRT Date: Mon, 12 Oct 2026 10:48:59 +0800 Subject: [PATCH] apple testflight groups: bare lists the groups and what the config would create (read-only); groups sync (or --yes) creates; flows: bare = read, sync = write; MCP tool runs groups sync --- CHANGELOG.md | 4 ++++ README.md | 2 +- cli/apple/testflight.ts | 21 ++++++++++++++++----- cli/data/flags.json | 3 ++- cli/lib/flow.ts | 1 + cli/mcp-tools.ts | 2 +- docs/idempotency.md | 2 +- skills/appstore-first-release/SKILL.md | 2 +- skills/testflight/SKILL.md | 2 +- tests/flow.test.ts | 3 +++ tests/idempotency.test.ts | 13 ++++++++++++- tests/testflight.test.ts | 2 +- 12 files changed, 44 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d54f7c..637c8af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Safety (`apple testflight groups`): run bare, it created the configured beta groups right away, with no plan. Now it only lists the groups and prints `would create "Team", "Friends"`. Creating is + `groups sync` (or `groups --yes`). Flows treat bare `groups` as a read and `groups sync` as a write, and the MCP tool + runs `groups sync` (dry by default). + - Fix (`apple resubmit`, `apple iap-submit`): after an Invalid Binary rejection the submit kept answering 409 "Version is not ready to be submitted yet" for 25 minutes because the app version item of the UNRESOLVED_ISSUES submission was still REJECTED (pitfall 30). REJECTED items are now marked resolved before the submit diff --git a/README.md b/README.md index 9e23a3f..fc7aa17 100644 --- a/README.md +++ b/README.md @@ -413,7 +413,7 @@ run in the background with a log (`storebound shots render > .storebound/render. | command | kind | what | |---|---|---| | `apple testflight status` | API, read-only | groups, testers and builds per group, recent builds with internal / external beta states | -| `apple testflight groups` | API | create the groups of `apple.testflight.groups` that are missing | +| `apple testflight groups [sync]` | API | list the groups (read-only); `sync` creates the ones of `apple.testflight.groups` that are missing | | `apple testflight testers [--csv f] [--remove] [--invite]` | API | testers from a CSV in `.storebound/` into their groups (asks first; emails masked) | | `apple testflight what-to-test --build N` | API | "What to Test" per locale from `store/appstore.md` (`whatToTest`, else `whatsNew`) | | `apple testflight info` | API | Beta App Description, feedback email, URLs, Beta App Review contact and notes | diff --git a/cli/apple/testflight.ts b/cli/apple/testflight.ts index fa3745e..2802809 100644 --- a/cli/apple/testflight.ts +++ b/cli/apple/testflight.ts @@ -1,6 +1,7 @@ // TestFlight through the App Store Connect API. // storebound apple testflight status [--json] groups, testers per group, recent builds + beta states -// storebound apple testflight groups [--dry] create the groups of apple.testflight.groups that are missing +// storebound apple testflight groups list the groups and what the config would create (read-only) +// storebound apple testflight groups sync [--dry] | groups --yes create the groups of apple.testflight.groups that are missing // storebound apple testflight testers [--csv f] [--group a,b] [--remove] [--invite] [--dry] [--yes] // storebound apple testflight what-to-test --build N [--version x.y.z] [--locale en-US,tr] [--text "..."] [--dry] // storebound apple testflight info [--dry] Test Information + Beta App Review contact / notes @@ -32,7 +33,7 @@ import { describeReq, execute } from './jsonapi'; import { findOrCreate, getById } from '../lib/upsert'; const TAG = 'testflight'; -const USAGE = 'usage: storebound apple testflight status | groups | testers | what-to-test --build N | info | distribute --build N --group | wait --build N | expire --keep N | --older-than D'; +const USAGE = 'usage: storebound apple testflight status | groups [sync] | testers | what-to-test --build N | info | distribute --build N --group | wait --build N | expire --keep N | --older-than D'; /** The state of build `number` (of `marketing` version) right now; found: false while App Store Connect does not list it. */ async function seeBuild(c: Asc, appId: string, number: string, marketing?: string): Promise { @@ -134,12 +135,22 @@ export async function run(ctx: Ctx): Promise { const d = details.get((b as J).relationships?.buildBetaDetail?.data?.id) as J; return [b.marketing ?? '', b.attributes.version ?? '', b.attributes.processingState ?? '', d?.internalBuildState ?? '', d?.externalBuildState ?? '', (b.attributes.uploadedDate ?? '').slice(0, 10)]; }))); - if (missingGroups.length) warn(TAG, `configured but not on App Store Connect: ${missingGroups.join(', ')} (storebound apple testflight groups)`); + if (missingGroups.length) warn(TAG, `configured but not on App Store Connect: ${missingGroups.join(', ')} (storebound apple testflight groups sync)`); return; } if (sub === 'groups') { - const plan = planGroups(appId, await groups(c, appId), tf?.groups ?? []); + const gs = await groups(c, appId); + const plan = planGroups(appId, gs, tf?.groups ?? []); + // bare `groups` only lists: creating is `groups sync` (or `groups --yes`); a listing-sounding command never writes + const write = a._[1] === 'sync' || a.has('yes'); + if (!write) { + for (const g of gs) log(TAG, `group "${g.name}": ${g.internal ? 'internal' : 'external'}${tf?.groups?.some(w => w.name === g.name) ? '' : ' (not in apple.testflight.groups)'}`); + if (!gs.length) log(TAG, 'no beta groups on App Store Connect yet'); + const names = plan.create.map(r => `"${(r.body as J).data.attributes.name}"`); + done(TAG, names.length ? `would create ${names.join(', ')} (from apple.testflight.groups): storebound apple testflight groups sync` : 'every configured group exists'); + return; + } 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 }); @@ -166,7 +177,7 @@ export async function run(ctx: Ctx): Promise { const resp = await c.all(`/v1/betaTesters?filter[apps]=${appId}&include=betaGroups&limit=200`); for (const t of resp) existing.set(String(t.attributes.email ?? '').toLowerCase(), { id: t.id, groupIds: (t.relationships?.betaGroups?.data ?? []).map((g: J) => g.id), state: t.attributes.state }); const plan = planTesters(gs, existing, want, { remove: a.has('remove'), only }); - if (plan.unknownGroups.length) throw new Error(`unknown group(s): ${plan.unknownGroups.join(', ')} (storebound apple testflight groups creates the configured ones)`); + if (plan.unknownGroups.length) throw new Error(`unknown group(s): ${plan.unknownGroups.join(', ')} (storebound apple testflight groups sync creates the configured ones)`); const internal = gs.filter(g => g.internal && want.some(t => t.groups.includes(g.name))); if (internal.length) warn(TAG, `internal groups (${internal.map(g => g.name).join(', ')}) only take App Store Connect users of your team (Users and Access)`); log(TAG, `${want.length} testers in the CSV: ${plan.summary.join(', ')}`); diff --git a/cli/data/flags.json b/cli/data/flags.json index f328f22..ede0d71 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -1004,7 +1004,8 @@ ], "apple testflight": [ "storebound apple testflight status [--json] groups, testers per group, recent builds + beta states", - "storebound apple testflight groups [--dry] create the groups of apple.testflight.groups that are missing", + "storebound apple testflight groups list the groups and what the config would create (read-only)", + "storebound apple testflight groups sync [--dry] | groups --yes create the groups of apple.testflight.groups that are missing", "storebound apple testflight testers [--csv f] [--group a,b] [--remove] [--invite] [--dry] [--yes]", "storebound apple testflight what-to-test --build N [--version x.y.z] [--locale en-US,tr] [--text \"...\"] [--dry]", "storebound apple testflight info [--dry] Test Information + Beta App Review contact / notes", diff --git a/cli/lib/flow.ts b/cli/lib/flow.ts index 8ae5881..238ade6 100644 --- a/cli/lib/flow.ts +++ b/cli/lib/flow.ts @@ -97,6 +97,7 @@ export function classify(argv: string[]): StepKind { const w = words(argv); if (OWNER.some(p => starts(w, p)) || argv.some(a => OWNER_ARGS.includes(a))) return argv.includes('--dry') && !argv.some(a => OWNER_ARGS.includes(a)) ? 'read' : 'owner'; if (WAIT.some(p => starts(w, p))) return 'wait'; + if (starts(w, ['apple', 'testflight', 'groups', 'sync'])) return 'write'; if (READ.some(p => starts(w, p))) return 'read'; // `api` reads run, writes stay dry inside the command itself; treat it as a write so it gets --dry return 'write'; diff --git a/cli/mcp-tools.ts b/cli/mcp-tools.ts index f878796..a0c61d8 100644 --- a/cli/mcp-tools.ts +++ b/cli/mcp-tools.ts @@ -236,7 +236,7 @@ export const TOOLS: ToolDef[] = [ args: i => [...opt('only', i.only), ...opt('poster', i.poster)] }, { name: 'apple_testflight_status', title: 'TestFlight status', command: ['apple', 'testflight', 'status'], write: false, network: true, description: 'Read-only TestFlight overview: beta groups (internal / external, tester and build counts, public link), recent builds with processing, internal and external beta states.' }, - { name: 'apple_testflight_groups', title: 'Create TestFlight groups', command: ['apple', 'testflight', 'groups'], write: true, network: true, idempotent: true, + { name: 'apple_testflight_groups', title: 'Create TestFlight groups', command: ['apple', 'testflight', 'groups', 'sync'], write: true, network: true, idempotent: true, description: 'Create the beta groups of apple.testflight.groups that are missing on App Store Connect (matched by name; existing groups are never changed or deleted). Sends nothing to testers.' }, { name: 'apple_testflight_what_to_test', title: 'TestFlight What to Test', command: ['apple', 'testflight', 'what-to-test'], write: true, network: true, idempotent: true, description: 'Set "What to Test" (betaBuildLocalizations) of one build, by its exact number, per locale from the App Store listing file (### whatToTest, else ### whatsNew) or from text.', diff --git a/docs/idempotency.md b/docs/idempotency.md index cd88de5..3fdf299 100644 --- a/docs/idempotency.md +++ b/docs/idempotency.md @@ -60,7 +60,7 @@ first. No write path sends the same values again on a second run. | `apple events upload` | asset type per locale (size + delivered) | kept when the same size is delivered, else replaced | ok | | `apple ppo create` | test name (open tests only); treatment name; locale | findOrCreate + journal for test and treatments | ok T | | `apple previews` | preview type; position + MD5; poster time code | like screenshots; poster PATCH only where it differs | ok | -| `apple testflight groups` | group name | findOrCreate + journal, 409 = found | ok T | +| `apple testflight groups sync` | group name | findOrCreate + journal, 409 = found | ok T | | `apple testflight testers` | email (masked) per group | only missing testers / memberships; 409 = exists | ok T (owner: asks) | | `apple testflight what-to-test` / `info` | locale; fields | POST missing locales, PATCH changed fields only | ok | | `apple testflight distribute` | group per build | relationship adds (same when sent twice); beta review once per build (409 = exists) | ok (owner) | diff --git a/skills/appstore-first-release/SKILL.md b/skills/appstore-first-release/SKILL.md index 53ae745..7fbec85 100644 --- a/skills/appstore-first-release/SKILL.md +++ b/skills/appstore-first-release/SKILL.md @@ -44,7 +44,7 @@ answers the compliance facts no file proves, all in one batch at the start. ExportOptions.plist uses `method` app-store-connect, `destination` export, your team and the signing style of `apple.signing.style` (automatic by default). Processing takes 5-30 min; `apple upload` waits for it. 6. **API · TestFlight.** Put an internal group with `allBuilds: true` in `apple.testflight.groups` and run - `storebound apple testflight groups`: every new build reaches the team automatically. External testers, What to + `storebound apple testflight groups sync`: every new build reaches the team automatically. External testers, What to Test, Test Information and Beta App Review: the testflight skill. Sandbox testers stay UI: Users and Access > Sandbox. 7. **API · Version + text.** Fill `store/appstore.md`, then `storebound apple listing --version 1.0.0 --build 3` (version, copyright, MANUAL release, the build with exactly that number, name / subtitle / privacy URL, diff --git a/skills/testflight/SKILL.md b/skills/testflight/SKILL.md index 0f70443..8e34ff7 100644 --- a/skills/testflight/SKILL.md +++ b/skills/testflight/SKILL.md @@ -30,7 +30,7 @@ apple: { }, }, ``` -`storebound apple testflight groups --dry`, then without `--dry`. Groups are matched by name; storebound never renames, +`storebound apple testflight groups` (lists them and what would be created), then `groups sync`. Groups are matched by name; storebound never renames, changes or deletes a group. ## 2. Testers (CSV in .storebound/, never in git) diff --git a/tests/flow.test.ts b/tests/flow.test.ts index 6a4cd08..a76662c 100644 --- a/tests/flow.test.ts +++ b/tests/flow.test.ts @@ -56,6 +56,9 @@ fold: > describe('steps', () => { test('classification: reads, writes, waits, owner steps', () => { expect(classify(['validate'])).toBe('read'); + // bare `testflight groups` only lists; `groups sync` creates (a listing-sounding command once created groups) + expect(classify(['apple', 'testflight', 'groups'])).toBe('read'); + expect(classify(['apple', 'testflight', 'groups', 'sync'])).toBe('write'); expect(classify(['release', 'plan', '1.2.0'])).toBe('read'); expect(classify(['apple', 'next-build'])).toBe('read'); expect(classify(['apple', 'upload', '--no-wait'])).toBe('write'); diff --git a/tests/idempotency.test.ts b/tests/idempotency.test.ts index ba26cfe..2beefd8 100644 --- a/tests/idempotency.test.ts +++ b/tests/idempotency.test.ts @@ -87,6 +87,17 @@ async function twice(run: () => Promise): Promise<{ first: string[]; secon const count = (type: string): number => s.asc.all(type).length; +describe('apple testflight groups: bare lists, sync creates', () => { + test('bare `groups` writes nothing and names what sync would create', async () => { + const p = project(CONFIG); + const before = s.calls.length; + const r = await capture(() => appleTestflight(p.ctx(['groups']))); + expect(writes(s.calls.slice(before))).toEqual([]); + expect(r.out).toContain('would create "Friends", "Public"'); + expect(r.out).toContain('storebound apple testflight groups sync'); + }); +}); + describe('App Store Connect: a second run writes nothing', () => { test('apple iap: product, localizations, price schedule, availability once', async () => { const p = project(CONFIG); @@ -119,7 +130,7 @@ describe('App Store Connect: a second run writes nothing', () => { for (const [name, run] of [ ['cpp', () => appleCpp(p.ctx(['create']))], ['events', () => appleEvents(p.ctx(['create']))], - ['testflight', () => appleTestflight(p.ctx(['groups']))], + ['testflight', () => appleTestflight(p.ctx(['groups', 'sync']))], ['review-details', () => appleReviewDetails(p.ctx([]))], ['bundle', () => appleBundle(p.ctx([]))], ['offer-codes', () => appleOfferCodes(p.ctx([]))], diff --git a/tests/testflight.test.ts b/tests/testflight.test.ts index da41337..0d885ba 100644 --- a/tests/testflight.test.ts +++ b/tests/testflight.test.ts @@ -140,7 +140,7 @@ describe('testflight mcp tools', () => { const tool = (n: string) => TOOLS.find(t => t.name === n)!; test('status read-only, writes dry by default, distribute is internal-only, testers / review not exposed', () => { expect(tool('apple_testflight_status')).toMatchObject({ write: false, network: true }); - expect(toArgv(tool('apple_testflight_groups')).argv).toEqual(['apple', 'testflight', 'groups', '--dry']); + expect(toArgv(tool('apple_testflight_groups')).argv).toEqual(['apple', 'testflight', 'groups', 'sync', '--dry']); expect(toArgv(tool('apple_testflight_what_to_test'), { build: '42', locale: 'en-US,tr' }).argv).toEqual(['apple', 'testflight', 'what-to-test', '--build', '42', '--locale', 'en-US,tr', '--dry']); expect(toArgv(tool('apple_testflight_distribute'), { build: '42', groups: 'Team', dry_run: false }).argv).toEqual(['apple', 'testflight', 'distribute', '--build', '42', '--group', 'Team', '--internal-only']); expect(validateInput(tool('apple_testflight_distribute'), { build: 'latest', groups: 'Team' })[0]).toContain('does not match');