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

@ -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') {