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:
parent
f741c15829
commit
bba46b5c7f
13 changed files with 354 additions and 14 deletions
|
|
@ -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(','))}`);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue