apple testflight distribute: a build's groups read per group (GET /v1/betaGroups/{id}/relationships/builds) instead of GET /v1/betaGroups?filter[builds]=, which App Store Connect refuses with 400 although the spec declares it; internal all-builds groups reported as already available and never sent the build; a re-run adds nothing; configured groups missing on App Store Connect point at groups sync, unknown ones list what exists; APPLE_LIVE_REJECTED (refused by storebound api too), tests/asc-query-guard.test.ts checks every App Store Connect GET with a query in cli/ against the spec's declared parameters, and the fake App Store Connect answers 400 to undeclared or live-refused parameters (pitfall 42)

This commit is contained in:
CRTkafa 2026-10-13 06:03:16 +08:00
parent f741c15829
commit bba46b5c7f
13 changed files with 354 additions and 14 deletions

View file

@ -33,6 +33,15 @@ export interface Op {
shapes: Record<string, string>;
}
/**
* App Store Connect query parameters the spec declares but the live API answers 400 to, per operation id, with what to
* use instead. `storebound api` refuses them, and tests/asc-query-guard.test.ts fails when storebound's own code uses one.
*/
export const APPLE_LIVE_REJECTED: Record<string, Record<string, string>> = {
// seen live 2026-10-13 (apple testflight distribute): GET /v1/betaGroups?filter[app]=..&filter[builds]=.. -> 400
betaGroups_getCollection: { 'filter[builds]': 'seen live: 400; read the builds of each group with GET /v1/betaGroups/{id}/relationships/builds' },
};
/** Query parameters every Google API accepts on top of a method's own. */
export const GOOGLE_GLOBAL = ['fields', 'prettyPrint', 'quotaUser'];
@ -136,6 +145,8 @@ export function buildRequest(op: Op, params: Record<string, ParamValue | undefin
const names = op.query.map(x => x.name);
throw new Error(`${op.id}: unknown parameter "${k}"${names.length ? ` (takes: ${names.slice(0, 30).join(', ')}${names.length > 30 ? ', …' : ''})` : ' (takes no query parameters)'}`);
}
const refused = op.store === 'apple' ? APPLE_LIVE_REJECTED[op.id]?.[k] : undefined;
if (refused) throw new Error(`${op.id}: App Store Connect refuses "${k}" although the spec declares it (${refused})`);
const values = Array.isArray(v) ? v.map(str) : [str(v as string)];
if (q?.repeated) for (const x of values) parts.push(`${encName(k)}=${encVal(x)}`);
else parts.push(`${encName(k)}=${encVal(values.join(','))}`);

View file

@ -98,8 +98,8 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'betaGroups_betaTesters_getToManyRelated': 'apple/testflight',
'betaGroups_builds_createToManyRelationship': 'lib/testflight',
'betaGroups_builds_getToManyRelated': 'apple/testflight',
'betaGroups_builds_getToManyRelationship': 'apple/testflight',
'betaGroups_createInstance': 'lib/testflight',
'betaGroups_getCollection': 'apple/testflight',
'betaGroups_getInstance': 'apple/testflight',
'betaTesterInvitations_createInstance': 'lib/testflight',
'betaTesters_createInstance': 'lib/testflight',

View file

@ -25,8 +25,8 @@ import { textTable } from '../lib/table';
import { BUILD_WAIT_EXIT, type BuildSeen, expireCandidates, planExpire, validHint, waitDelay, type WaitOutcome, waitOutcome } from '../lib/build-ops';
import { parseDuration } from '../lib/release';
import {
type ApiBuild, betaDescriptions, betaReadiness, betaReviewReq, buildsWithVersion, type Group, groupFromApi, inviteReq, maskEmail,
parseTesters, pickBuild, planBetaInfo, planBetaReviewDetail, planDistribute, planGroups, planTesters, planWhatToTest, whatToTestFromListing,
type ApiBuild, betaDescriptions, betaReadiness, betaReviewReq, buildsWithVersion, getsAllBuilds, type Group, groupFromApi, inviteReq, maskEmail,
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';
@ -178,7 +178,7 @@ export async function run(ctx: Ctx): Promise<void> {
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 sync creates the configured ones)`);
if (plan.unknownGroups.length) throw new Error(unknownGroupMessage(plan.unknownGroups, gs, tf?.groups ?? []));
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(', ')}`);
@ -239,14 +239,25 @@ export async function run(ctx: Ctx): Promise<void> {
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 target = names.map(n => gs.find(g => g.name === n) ?? (() => { throw new Error(`unknown group "${n}" (${gs.map(g => g.name).join(', ')})`); })());
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)!);
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'));
const problems = betaReadiness(b.attributes, b.detail);
if (problems.length) throw new Error(`build ${number}: ${problems.join('; ')}`);
log(TAG, `build ${number} (${b.marketing ?? '?'}): internal ${b.detail?.internalBuildState ?? '?'}, external ${b.detail?.externalBuildState ?? '?'}`);
const inGroups = (await c.all(`/v1/betaGroups?filter[app]=${appId}&filter[builds]=${b.id}&limit=200`)).map((g: J) => g.id);
// a build's groups, read per group: GET /v1/betaGroups?filter[builds]= is refused live (400) and the spec has no
// GET /v1/builds/{id}/betaGroups; internal all-builds groups have every build and take none by hand
const inGroups: string[] = [];
for (const g of target) {
if (getsAllBuilds(g)) { log(TAG, `build ${number}: already available to ${g.name} (internal, all builds)`); continue; }
if ((await c.all(`/v1/betaGroups/${g.id}/relationships/builds?limit=200`)).some((x: J) => x.id === b.id)) {
inGroups.push(g.id);
log(TAG, `build ${number}: already in ${g.name} (${g.internal ? 'internal' : 'external'})`);
}
}
const reqs = planDistribute(b.id, target, inGroups);
const review = a.has('submit-review') && external.length ? (await c.maybe(`/v1/builds/${b.id}/betaAppReviewSubmission`))?.data ?? null : null;
const wantsReview = a.has('submit-review') && external.length > 0 && !review && !['BETA_APPROVED', 'IN_BETA_REVIEW', 'WAITING_FOR_BETA_REVIEW', 'IN_BETA_TESTING'].includes(b.detail?.externalBuildState);
@ -264,7 +275,8 @@ export async function run(ctx: Ctx): Promise<void> {
if (await confirm(ctx, `Submit build ${number} to Beta App Review now? Apple reviews it for external testing (Test Information must be complete: storebound apple testflight info).`))
await execute(c.asc, [betaReviewReq(b.id)], { dry: false, tag: TAG });
}
done(TAG, `build ${number}: ${reqs.length ? `added to ${reqs.length} group(s)` : 'already in every group'}`);
const added = target.filter(g => reqs.some(r => r.path.includes(`/betaGroups/${g.id}/`))).map(g => g.name);
done(TAG, `build ${number}: ${added.length ? `added to ${added.join(', ')}` : `already available to ${target.map(g => g.name).join(', ')}`}`);
return;
}
if (sub === 'wait') {

View file

@ -179,12 +179,37 @@ export function planTesters(groups: Group[], existing: Map<string, { id: string;
export const inviteReq = (appId: string, testerId: string, label: string): Req => ({ method: 'POST', path: '/v1/betaTesterInvitations', note: `invite ${label}`,
body: { data: { type: 'betaTesterInvitations', relationships: { app: one('apps', appId), betaTester: one('betaTesters', testerId) } } } });
/** POST /v1/betaGroups/{id}/relationships/builds for every group that does not have the build yet. */
/** An internal group with hasAccessToAllBuilds gets every build by itself; App Store Connect refuses adding one by hand. */
export const getsAllBuilds = (g: Group): boolean => g.internal && g.hasAccessToAllBuilds === true;
/**
* POST /v1/betaGroups/{id}/relationships/builds for every group that does not have the build yet. Internal groups with
* all builds are skipped (they have it already). `buildGroupIds`: the groups that list the build, read per group with
* GET /v1/betaGroups/{id}/relationships/builds (App Store Connect answers 400 to GET /v1/betaGroups?filter[builds]=,
* and the spec has no GET of a build's groups).
*/
export function planDistribute(buildId: string, groups: Group[], buildGroupIds: string[]): Req[] {
return groups.filter(g => !buildGroupIds.includes(g.id) && !(g.internal && g.hasAccessToAllBuilds)).map(g => ({ method: 'POST', path: `/v1/betaGroups/${g.id}/relationships/builds`,
return groups.filter(g => !buildGroupIds.includes(g.id) && !getsAllBuilds(g)).map(g => ({ method: 'POST', path: `/v1/betaGroups/${g.id}/relationships/builds`,
body: many('builds', [buildId]), note: `build -> ${g.internal ? 'internal' : 'external'} group "${g.name}"` }));
}
/** Why group names given to distribute / testers are not on App Store Connect: configured but not created yet (with the
* command that creates them), or unknown (with the groups that do exist). Pure. */
export function unknownGroupMessage(names: string[], existing: Group[], configured: TfGroupSpec[]): string {
const sync = '`storebound apple testflight groups sync`';
const quote = (ns: string[]) => ns.map(n => `"${n}"`).join(', ');
const inConfig = names.filter(n => configured.some(g => g.name === n));
const other = names.filter(n => !inConfig.includes(n));
const lines: string[] = [];
if (inConfig.length) lines.push(inConfig.length > 1
? `groups ${quote(inConfig)} are in the config but not on App Store Connect yet: create them with ${sync}`
: `group ${quote(inConfig)} is in the config but not on App Store Connect yet: create it with ${sync}`);
if (other.length) lines.push(`unknown group${other.length > 1 ? 's' : ''} ${quote(other)}: ${existing.length
? `App Store Connect has ${quote(existing.map(g => g.name))}`
: `App Store Connect has no beta groups yet (add them to apple.testflight.groups, then ${sync})`}`);
return lines.join('; ');
}
/**
* "What to Test" texts for TestFlight per App Store locale from the App Store listing file: a `### whatToTest` block
* when a locale section has one, else its `### whatsNew` block (what `storebound whats-new` drafts). Pure.