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

This commit is contained in:
CRTkafa 2026-10-12 10:48:59 +08:00
parent 1724296e59
commit 41d2ffcc24
12 changed files with 44 additions and 13 deletions

View file

@ -2,6 +2,10 @@
## Unreleased ## 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 - 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 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 was still REJECTED (pitfall 30). REJECTED items are now marked resolved before the submit

View file

@ -413,7 +413,7 @@ run in the background with a log (`storebound shots render > .storebound/render.
| command | kind | what | | command | kind | what |
|---|---|---| |---|---|---|
| `apple testflight status` | API, read-only | groups, testers and builds per group, recent builds with internal / external beta states | | `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 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 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 | | `apple testflight info` | API | Beta App Description, feedback email, URLs, Beta App Review contact and notes |

View file

@ -1,6 +1,7 @@
// TestFlight through the App Store Connect API. // TestFlight through the App Store Connect API.
// storebound apple testflight status [--json] groups, testers per group, recent builds + beta states // 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 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 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 // 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'; import { findOrCreate, getById } from '../lib/upsert';
const TAG = 'testflight'; const TAG = 'testflight';
const USAGE = 'usage: storebound apple testflight status | groups | testers | what-to-test --build N | info | distribute --build N --group <names> | 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 <names> | 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. */ /** 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<BuildSeen & { version?: string }> { async function seeBuild(c: Asc, appId: string, number: string, marketing?: string): Promise<BuildSeen & { version?: string }> {
@ -134,12 +135,22 @@ export async function run(ctx: Ctx): Promise<void> {
const d = details.get((b as J).relationships?.buildBetaDetail?.data?.id) as J; 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)]; 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; return;
} }
if (sub === 'groups') { 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'); 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`); for (const n of plan.existing) log(TAG, `group "${n}": exists`);
if (dry) await execute(c.asc, plan.create, { dry, tag: TAG }); if (dry) await execute(c.asc, plan.create, { dry, tag: TAG });
@ -166,7 +177,7 @@ export async function run(ctx: Ctx): Promise<void> {
const resp = await c.all(`/v1/betaTesters?filter[apps]=${appId}&include=betaGroups&limit=200`); 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 }); 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 }); 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))); 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)`); 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(', ')}`); log(TAG, `${want.length} testers in the CSV: ${plan.summary.join(', ')}`);

View file

@ -1004,7 +1004,8 @@
], ],
"apple testflight": [ "apple testflight": [
"storebound apple testflight status [--json] groups, testers per group, recent builds + beta states", "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 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 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", "storebound apple testflight info [--dry] Test Information + Beta App Review contact / notes",

View file

@ -97,6 +97,7 @@ export function classify(argv: string[]): StepKind {
const w = words(argv); 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 (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 (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'; 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 // `api` reads run, writes stay dry inside the command itself; treat it as a write so it gets --dry
return 'write'; return 'write';

View file

@ -236,7 +236,7 @@ export const TOOLS: ToolDef[] = [
args: i => [...opt('only', i.only), ...opt('poster', i.poster)] }, 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, { 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.' }, 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.' }, 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, { 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.', 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.',

View file

@ -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 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 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 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 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 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) | | `apple testflight distribute` | group per build | relationship adds (same when sent twice); beta review once per build (409 = exists) | ok (owner) |

View file

@ -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 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. `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 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. 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` 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, (version, copyright, MANUAL release, the build with exactly that number, name / subtitle / privacy URL,

View file

@ -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. changes or deletes a group.
## 2. Testers (CSV in .storebound/, never in git) ## 2. Testers (CSV in .storebound/, never in git)

View file

@ -56,6 +56,9 @@ fold: >
describe('steps', () => { describe('steps', () => {
test('classification: reads, writes, waits, owner steps', () => { test('classification: reads, writes, waits, owner steps', () => {
expect(classify(['validate'])).toBe('read'); 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(['release', 'plan', '1.2.0'])).toBe('read');
expect(classify(['apple', 'next-build'])).toBe('read'); expect(classify(['apple', 'next-build'])).toBe('read');
expect(classify(['apple', 'upload', '--no-wait'])).toBe('write'); expect(classify(['apple', 'upload', '--no-wait'])).toBe('write');

View file

@ -87,6 +87,17 @@ async function twice(run: () => Promise<void>): Promise<{ first: string[]; secon
const count = (type: string): number => s.asc.all(type).length; 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', () => { describe('App Store Connect: a second run writes nothing', () => {
test('apple iap: product, localizations, price schedule, availability once', async () => { test('apple iap: product, localizations, price schedule, availability once', async () => {
const p = project(CONFIG); const p = project(CONFIG);
@ -119,7 +130,7 @@ describe('App Store Connect: a second run writes nothing', () => {
for (const [name, run] of [ for (const [name, run] of [
['cpp', () => appleCpp(p.ctx(['create']))], ['cpp', () => appleCpp(p.ctx(['create']))],
['events', () => appleEvents(p.ctx(['create']))], ['events', () => appleEvents(p.ctx(['create']))],
['testflight', () => appleTestflight(p.ctx(['groups']))], ['testflight', () => appleTestflight(p.ctx(['groups', 'sync']))],
['review-details', () => appleReviewDetails(p.ctx([]))], ['review-details', () => appleReviewDetails(p.ctx([]))],
['bundle', () => appleBundle(p.ctx([]))], ['bundle', () => appleBundle(p.ctx([]))],
['offer-codes', () => appleOfferCodes(p.ctx([]))], ['offer-codes', () => appleOfferCodes(p.ctx([]))],

View file

@ -140,7 +140,7 @@ describe('testflight mcp tools', () => {
const tool = (n: string) => TOOLS.find(t => t.name === n)!; 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', () => { 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(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_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(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'); expect(validateInput(tool('apple_testflight_distribute'), { build: 'latest', groups: 'Team' })[0]).toContain('does not match');