apple: release type (apple.releaseType, apple release --after-approval|--manual), device-family preflight, TestFlight invite needs an installable build

This commit is contained in:
CRTkafa 2026-10-27 17:07:37 +07:00
parent 4763862178
commit 85cf494c0a
24 changed files with 899 additions and 18 deletions

View file

@ -26,7 +26,7 @@ import { BUILD_WAIT_EXIT, type BuildSeen, expireCandidates, planExpire, validHin
import { parseDuration } from '../lib/release';
import {
type ApiBuild, betaDescriptions, betaReadiness, betaReviewReq, buildsWithVersion, getsAllBuilds, type Group, groupFromApi, inviteReq, maskEmail,
parseTesters, pickBuild, planBetaInfo, planBetaReviewDetail, planDistribute, planGroups, planTesters, planWhatToTest, unknownGroupMessage, whatToTestFromListing,
installableBuild, noInstallableBuildHint, noInstallableBuilds409, parseTesters, pickBuild, planBetaInfo, planBetaReviewDetail, planDistribute, planGroups, planTesters, planWhatToTest, unknownGroupMessage, whatToTestFromListing,
} from '../lib/testflight';
import { type Asc, ascClient, type J } from './asc';
import { describeReq, execute, type Req } from './jsonapi';
@ -113,6 +113,17 @@ async function findBuild(c: Asc, appId: string, number: string, marketing?: stri
return { ...b, detail };
}
/** Whether the group has a build its testers can install now (past Beta App Review for an external group). An internal
* all-builds group has every build of the app. */
async function groupHasInstallableBuild(c: Asc, appId: string, g: Group): Promise<boolean> {
const builds = getsAllBuilds(g) ? await c.all(`/v1/builds?filter[app]=${appId}&limit=200`) : await c.all(`/v1/betaGroups/${g.id}/builds?limit=200`);
for (const b of builds) {
const detail = (await c.maybe(`/v1/builds/${b.id}/buildBetaDetail`))?.data?.attributes ?? null;
if (installableBuild(b, detail, g.internal)) return true;
}
return false;
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const a = ctx.args;
@ -193,9 +204,35 @@ export async function run(ctx: Ctx): Promise<void> {
await execute(c.asc, reqs, { dry, tag: TAG });
if (a.has('invite')) {
const fresh = new Map((await c.all(`/v1/betaTesters?filter[apps]=${appId}&limit=200`)).map((t: J) => [String(t.attributes.email ?? '').toLowerCase(), t] as const));
const pending = want.map(t => fresh.get(t.email)).filter((t): t is J => !!t && ['NOT_INVITED', 'INVITED'].includes(t.attributes.state));
if (pending.length && await confirm(ctx, `(Re)send the TestFlight invitation email to ${pending.length} tester(s)?`))
await execute(c.asc, pending.map(t => inviteReq(appId, t.id, maskEmail(String(t.attributes.email)))), { dry: false, tag: TAG });
// an invitation needs a build the tester can install; without one Apple answers 409 NO_INSTALLABLE_BUILDS (seen live
// on a new external group): add the tester, skip the invite, say when it can go out
const installable = new Map<string, boolean>();
const ready: { t: J; groups: string[] }[] = [], waiting = new Set<string>();
let waitingTesters = 0;
for (const w of want) {
const t = fresh.get(w.email);
if (!t || !['NOT_INVITED', 'INVITED'].includes(t.attributes.state)) continue;
const own = gs.filter(g => w.groups.includes(g.name));
for (const g of own) if (!installable.has(g.id)) installable.set(g.id, await groupHasInstallableBuild(c, appId, g));
if (own.some(g => installable.get(g.id))) ready.push({ t, groups: own.map(g => g.name) });
else { waitingTesters++; for (const g of own) waiting.add(g.name); }
}
if (waitingTesters) {
const msg = `${waitingTesters} ${noInstallableBuildHint([...waiting])}`;
warn(TAG, msg);
}
if (ready.length && await confirm(ctx, `(Re)send the TestFlight invitation email to ${ready.length} tester(s)?`)) {
for (const { t, groups } of ready) {
try { await execute(c.asc, [inviteReq(appId, t.id, maskEmail(String(t.attributes.email)))], { dry: false, tag: TAG }); }
catch (e) {
if (!noInstallableBuilds409(e)) throw e;
const msg = `1 ${noInstallableBuildHint(groups)}`;
warn(TAG, msg);
}
}
}
}
done(TAG, 'testers done');
return;
@ -293,6 +330,12 @@ 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 });
}
// testers added to an external group but not invited yet: the invitation needs a build past Beta App Review
if (a.has('submit-review')) {
const waiting: string[] = [];
for (const g of external) if ((await c.all(`/v1/betaGroups/${g.id}/betaTesters?limit=200`)).some((t: J) => t.attributes.state === 'NOT_INVITED')) waiting.push(g.name);
if (waiting.length) { log(TAG, noInstallableBuildHint(waiting)); }
}
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;