apple: release type (apple.releaseType, apple release --after-approval|--manual), device-family preflight, TestFlight invite needs an installable build
This commit is contained in:
parent
4763862178
commit
85cf494c0a
24 changed files with 899 additions and 18 deletions
|
|
@ -36,6 +36,8 @@ import { pump } from '../lib/runner';
|
|||
import { parseMobileprovision, toXmlPlist } from '../lib/plist';
|
||||
import { nextBuildNumber } from '../lib/release';
|
||||
import { exportOptions, profileDirs } from '../lib/signing';
|
||||
import { type FamilyVerdict, familyVerdicts, projectFamilies } from '../lib/device-families';
|
||||
import { liveDeviceFamilies } from './device-families';
|
||||
import { keychainPassword, keychainSigningArgs, parseKeychainList, showSecurity, sshBuildRefusal, unlockSteps } from '../lib/keychain';
|
||||
import { appGroupSteps, appGroupVerdicts, type GroupEvidence } from '../lib/targets';
|
||||
import { capacitorProject, webStep } from '../lib/web-assets';
|
||||
|
|
@ -186,6 +188,18 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (build && !/^\d+(\.\d+){0,2}$/.test(build)) throw new Error('--build-number: digits, e.g. 42 or 1.0.42');
|
||||
const buildNo = build ?? '<next build number>';
|
||||
|
||||
// device families: an upload that drops one the live version supports (iPad) is refused only AFTER the archive (ITMS-90101)
|
||||
// so compare first: stop before building with the exact fix (--allow-device-gap builds anyway)
|
||||
let deviceFamilies: FamilyVerdict[] = [];
|
||||
try {
|
||||
const c = await ascClient(root, bundleId, config.apple?.appId);
|
||||
deviceFamilies = familyVerdicts({ live: await liveDeviceFamilies(c, await c.app()), project: projectFamilies(root, bundleId, target.project), ipad: config.apple?.ipad });
|
||||
} catch (e) { warn(TAG, `device families of the live version not compared (${(e as Error).message.split('\n')[0]?.slice(0, 120)})`); }
|
||||
const familyBlockers = deviceFamilies.filter(d => d.level === 'blocker');
|
||||
for (const d of deviceFamilies) if (d.level === 'warn' || (d.level === 'blocker' && (dry || a.has('allow-device-gap')))) warn(TAG, `${d.msg}${d.fix ? ` -> ${d.fix}` : ''}`);
|
||||
if (familyBlockers.length && !dry && !a.has('allow-device-gap'))
|
||||
throw new Error(`stopped before the archive: ${familyBlockers.map(d => `${d.msg}. Fix: ${d.fix}`).join('\n')}\n(--allow-device-gap builds anyway)`);
|
||||
|
||||
const scan = discoverTargets(root, config);
|
||||
const ids = [bundleId, ...scan.extensions.map(t => t.bundleId)];
|
||||
const profiles: Record<string, string> = {};
|
||||
|
|
@ -232,7 +246,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
];
|
||||
const unlockPreview = unlock ? unlockSteps(keychain ?? '<login keychain>', []).map(showSecurity) : [];
|
||||
const plan = { framework: target.framework, workspace: target.workspace ?? null, project: target.project ?? null, scheme: target.scheme, configuration,
|
||||
version: version ?? '(project value)', build: buildNo, derivedData: derivedData ?? '(Xcode default)', pathFree, teamId, signingStyle: style, exportOptions: opts, out: relative(root, out) || '.', steps: [...unlockPreview, ...steps], notes: target.notes };
|
||||
version: version ?? '(project value)', build: buildNo, deviceFamilies, derivedData: derivedData ?? '(Xcode default)', pathFree, teamId, signingStyle: style, exportOptions: opts, out: relative(root, out) || '.', steps: [...unlockPreview, ...steps], notes: target.notes };
|
||||
const webOpts = { config, root, cap, store: 'apple' as const, tag: TAG, noPrebuild: a.has('no-prebuild'), allowStale: a.has('allow-stale-web') };
|
||||
if (dry) {
|
||||
const web = await webStep({ ...webOpts, dry: true, quiet: json });
|
||||
|
|
|
|||
41
cli/apple/device-families.ts
Normal file
41
cli/apple/device-families.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// The device families of the app's last live iOS version, read from App Store Connect (cli/lib/device-families.ts compares
|
||||
// them with the project and the config). Signals, merged because none is documented as THE answer:
|
||||
// - the live version's build bundle: buildBundles.deviceProtocols and the device models of buildBundleFileSizes
|
||||
// - the screenshot sets of the live version: an APP_IPAD_* display type means the version was submitted for iPad
|
||||
// Live version = READY_FOR_SALE / READY_FOR_DISTRIBUTION; else the newest one that was on sale (REPLACED_WITH_NEW_VERSION).
|
||||
import { compareVersions } from '../lib/listing-plan';
|
||||
import { liveFamilies, type LiveFamilies } from '../lib/device-families';
|
||||
import type { Asc, J } from './asc';
|
||||
import { type AscPlatform, versionsPath } from './platform';
|
||||
|
||||
const ON_SALE = ['READY_FOR_SALE', 'READY_FOR_DISTRIBUTION'];
|
||||
const WAS_ON_SALE = [...ON_SALE, 'REPLACED_WITH_NEW_VERSION'];
|
||||
|
||||
/** The live (or last live) version among the rows of GET appStoreVersions, newest first. Pure. */
|
||||
export function lastLiveVersion(rows: J[]): J | null {
|
||||
const state = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState;
|
||||
const pick = (states: string[]): J | null => rows.filter(v => states.includes(state(v)))
|
||||
.sort((a, b) => compareVersions(String(b.attributes.versionString), String(a.attributes.versionString)))[0] ?? null;
|
||||
return pick(ON_SALE) ?? pick(WAS_ON_SALE);
|
||||
}
|
||||
|
||||
/** Reads the families of the last live version of the platform (iOS unless named); null when there is none or no signal names a family. */
|
||||
export async function liveDeviceFamilies(c: Asc, appId: string, platform: AscPlatform = 'IOS'): Promise<LiveFamilies | null> {
|
||||
const v = lastLiveVersion(await c.all(versionsPath(appId, platform, 20)));
|
||||
if (!v) return null;
|
||||
const signals: { name: string; values: string[] }[] = [];
|
||||
const b = (await c.maybe(`/v1/appStoreVersions/${v.id}/build`))?.data;
|
||||
if (b) {
|
||||
const full = await c.maybe(`/v1/builds/${b.id}?include=buildBundles`);
|
||||
const bundles = (full?.included ?? []).filter((x: J) => x.type === 'buildBundles' && (x.attributes?.bundleType ?? 'APP') === 'APP');
|
||||
signals.push({ name: `build ${b.attributes?.version ?? b.id} deviceProtocols`, values: bundles.flatMap((x: J) => (x.attributes?.deviceProtocols ?? []) as string[]) });
|
||||
const models: string[] = [];
|
||||
for (const x of bundles) for (const f of await c.all(`/v1/buildBundles/${x.id}/buildBundleFileSizes?limit=200`)) models.push(String(f.attributes?.deviceModel ?? ''));
|
||||
signals.push({ name: `build ${b.attributes?.version ?? b.id} device models`, values: models });
|
||||
}
|
||||
const types: string[] = [];
|
||||
for (const l of (await c.all(`/v1/appStoreVersions/${v.id}/appStoreVersionLocalizations?limit=50`)).slice(0, 5))
|
||||
for (const s of await c.all(`/v1/appStoreVersionLocalizations/${l.id}/appScreenshotSets?limit=50`)) types.push(String(s.attributes?.screenshotDisplayType ?? ''));
|
||||
signals.push({ name: 'screenshot sets', values: types });
|
||||
return liveFamilies(String(v.attributes.versionString), String(v.attributes.appVersionState ?? v.attributes.appStoreState), signals);
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
// refuse with the reason), the build, and per locale create / update with old -> new per field. --dry stops there
|
||||
// (read-only); the real run asks in a terminal and applies exactly that plan. Locales marked status=draft or
|
||||
// status=translate (listing translate) are skipped unless --include-drafts.
|
||||
// - the editable iOS version: versionString, copyright, release type MANUAL; with --build that processed build is attached
|
||||
// - the editable iOS version: versionString, copyright, release type (apple.releaseType, MANUAL when new and unset); with --build that processed build is attached
|
||||
// - appInfoLocalizations: name, subtitle, privacy policy URL per locale
|
||||
// - appStoreVersionLocalizations: description, keywords, promotional text, what's new (not on a first version),
|
||||
// support + marketing URL per locale
|
||||
|
|
@ -21,6 +21,7 @@ import { mergeAppleListing, overLimits } from '../lib/listing-pull';
|
|||
import { counts, draftReason, draftSections, type ItemPlan, itemLines, planItem, planLocales, planVersion, short, type Skipped, type VersionPlan } from '../lib/listing-plan';
|
||||
import { createOnce, findOrCreate, getById } from '../lib/upsert';
|
||||
import { appleName } from '../lib/config';
|
||||
import { wantedRelease } from '../lib/release-type';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { confirm, need } from '../lib/ctx';
|
||||
import { done, log, printJson, warn } from '../lib/log';
|
||||
|
|
@ -117,7 +118,8 @@ async function planListing(ctx: Ctx, c: Asc, appId: string, version: string, pla
|
|||
}
|
||||
const copyright = app.copyright ?? `${new Date().getFullYear()} ${app.name}`;
|
||||
const rows = await c.all(versionsPath(appId, platform));
|
||||
const versionPlan = planVersion(rows, version, { copyright, releaseType: 'MANUAL' }, platform);
|
||||
const rel = wantedRelease(config.apple);
|
||||
const versionPlan = planVersion(rows, version, { copyright, releaseType: rel?.releaseType, earliestReleaseDate: rel?.earliestReleaseDate }, platform);
|
||||
|
||||
let build: AppleListingPlan['build'] = null;
|
||||
const number = ctx.args.get('build');
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
// Release management for an App Store version.
|
||||
// storebound apple release --phased on|pause|resume|complete [--version 1.2.0] [--dry] [--yes]
|
||||
// storebound apple release --manual-release [--version 1.2.0] [--first-release] [--yes] [--json]
|
||||
// storebound apple release --after-approval | --manual | --scheduled <ISO date> [--version 1.2.0] [--first-release] [--yes] [--json]
|
||||
// Both take --platform ios|macos|tvos|visionos; without it the platform that has the version this needs is used (both: asks).
|
||||
// Phased release: 7-day automatic rollout to users with automatic updates (1, 2, 5, 10, 20, 50, 100 %); anyone can
|
||||
// still update by hand from the App Store. Pause holds it (up to 30 days in total), it is NOT a rollback: users who
|
||||
// updated keep the build. "complete" releases to everyone at once.
|
||||
// --after-approval / --manual / --scheduled set how the version goes live (appStoreVersions.releaseType; the config's
|
||||
// apple.releaseType does the same in `apple listing` and `apple submit`). Allowed until App Review approves the version
|
||||
// (Prepare for Submission, rejected, Waiting for Review, In Review); an approved one (Pending Developer Release) is
|
||||
// released with --manual-release, a released one is final. Setting it when it is that type already is exit 7 under the
|
||||
// ship-step contract. An update of an app on sale is an agent step; --after-approval on the first version of an app
|
||||
// puts it on sale by itself, so that one is the owner's (exit 3 OWNER_STEP unless --first-release).
|
||||
// --manual-release releases an approved version (PENDING_DEVELOPER_RELEASE). Public and irreversible: asks first
|
||||
// (--yes in scripts). Releasing an UPDATE of an app already on sale is an agent step (no owner gate, also in a
|
||||
// pipeline; under the ship-step contract: exit 7 when the version is released already). The first-ever release of an
|
||||
|
|
@ -18,6 +25,7 @@ import { consoleUrl } from '../lib/console-links';
|
|||
import { appleReleaseKind, enforceFirstRelease } from '../lib/release-kind';
|
||||
import { type PhasedAction, phasedRequest } from '../lib/release';
|
||||
import { createOnce } from '../lib/upsert';
|
||||
import { ASC_RELEASE_TYPE, changeText, RELEASE_TYPE_EDITABLE, releaseTypeChanges, type WantedRelease } from '../lib/release-type';
|
||||
import { ascClient, type J } from './asc';
|
||||
import { allVersions, parsePlatform, pickPlatform, platformLabel, versionsPath } from './platform';
|
||||
|
||||
|
|
@ -26,12 +34,16 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const a = ctx.args;
|
||||
const phased = a.get('phased') as PhasedAction | undefined;
|
||||
const manual = a.has('manual-release');
|
||||
if (!phased && !manual) throw new Error('usage: apple release --phased on|pause|resume|complete | --manual-release [--version x.y.z] [--dry] [--yes]');
|
||||
const typeFlags = [a.has('after-approval') && 'afterApproval', a.has('manual') && 'manual', a.has('scheduled') && 'scheduled'].filter((x): x is 'afterApproval' | 'manual' | 'scheduled' => !!x);
|
||||
if (typeFlags.length > 1) throw new Error('--after-approval, --manual and --scheduled are alternatives: give one');
|
||||
if (!phased && !manual && !typeFlags.length) throw new Error('usage: apple release --phased on|pause|resume|complete | --manual-release | --after-approval | --manual | --scheduled <ISO date> [--version x.y.z] [--dry] [--yes]');
|
||||
if (typeFlags.length && (phased || manual)) throw new Error('the release type flags (--after-approval, --manual, --scheduled) are a command of their own: run --phased / --manual-release separately');
|
||||
if (phased && !['on', 'pause', 'resume', 'complete'].includes(phased)) throw new Error('--phased: on | pause | resume | complete');
|
||||
const dry = a.has('dry');
|
||||
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||
const appId = await c.app();
|
||||
const want = a.get('version');
|
||||
if (typeFlags.length) return setReleaseType(ctx, c, appId, typeFlags[0]!);
|
||||
const state = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState;
|
||||
shipReport({ version: want, url: consoleUrl('asc.version') });
|
||||
// the ship-step contract: the version is released already (and none waits for release): nothing to do, exit 7
|
||||
|
|
@ -96,3 +108,52 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
}
|
||||
done('apple', `${tag}: ${req.note}`);
|
||||
}
|
||||
|
||||
/** apple release --after-approval | --manual | --scheduled: PATCH appStoreVersions releaseType on a version App Review has not approved yet. */
|
||||
async function setReleaseType(ctx: Ctx, c: Awaited<ReturnType<typeof ascClient>>, appId: string, name: 'afterApproval' | 'manual' | 'scheduled'): Promise<void> {
|
||||
const a = ctx.args;
|
||||
const TAG = 'apple';
|
||||
const want = a.get('version');
|
||||
const state = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState;
|
||||
const date = name === 'scheduled' ? a.required('scheduled', 'ISO date and time', 'e.g. --scheduled 2027-03-01T09:00:00Z') : undefined;
|
||||
if (date && Number.isNaN(Date.parse(date))) throw new Error(`--scheduled ${date}: an ISO 8601 date and time, e.g. 2027-03-01T09:00:00Z`);
|
||||
const target: WantedRelease = { releaseType: ASC_RELEASE_TYPE[name], ...(date ? { earliestReleaseDate: date } : {}) };
|
||||
shipReport({ version: want, url: consoleUrl('asc.version') });
|
||||
const fits = (x: J): boolean => (!want || x.attributes.versionString === want) && (RELEASE_TYPE_EDITABLE.has(state(x)) || state(x) === 'PENDING_DEVELOPER_RELEASE');
|
||||
const platform = parsePlatform(a.get('platform')) ?? pickPlatform(await allVersions(c, appId), want ? [fits, (x: J) => x.attributes.versionString === want] : fits, undefined, 'a version that is not approved yet');
|
||||
const versions = await c.all(versionsPath(appId, platform, 20));
|
||||
const v: J | undefined = want ? versions.find(x => x.attributes.versionString === want) : versions.find(x => RELEASE_TYPE_EDITABLE.has(state(x))) ?? versions.find(x => state(x) === 'PENDING_DEVELOPER_RELEASE');
|
||||
if (!v) throw new Error(`${platformLabel(platform)}: ${want ? `no version ${want}` : 'no version waiting for App Review (Prepare for Submission, rejected, Waiting for Review, In Review)'}`);
|
||||
const versionString = String(v.attributes.versionString);
|
||||
const tag = `${versionString} ${platformLabel(platform)} (${state(v)})`;
|
||||
const changes = releaseTypeChanges(v.attributes, target);
|
||||
const kind = appleReleaseKind(versions.map((x: J) => ({ versionString: String(x.attributes.versionString), state: state(x) })));
|
||||
shipReport({ version: versionString, state: state(v) });
|
||||
const result = (extra: Record<string, unknown>): void => { if (a.has('json')) printJson({ version: versionString, state: state(v), platform, releaseType: target.releaseType, from: v.attributes.releaseType ?? null, releaseKind: kind.kind, ...extra }); };
|
||||
if (!changes.length) {
|
||||
shipNoop({ version: versionString, state: state(v) });
|
||||
result({ changed: false, alreadyThere: true });
|
||||
done(TAG, `${tag}: release type is ${target.releaseType} already: nothing changed`);
|
||||
return;
|
||||
}
|
||||
if (!RELEASE_TYPE_EDITABLE.has(state(v))) {
|
||||
throw new Error(`${tag}: the release type can no longer be changed${state(v) === 'PENDING_DEVELOPER_RELEASE' ? ` (App Review approved it and it waits for you: release it with storebound apple release --manual-release --version ${versionString})` : ' (it is approved or released; App Store Connect only takes the change before approval)'}`);
|
||||
}
|
||||
if (a.has('dry')) {
|
||||
log(TAG, `would set the release type of ${tag}: ${changeText(changes)} (dry)`);
|
||||
result({ changed: false, dryRun: true, changes });
|
||||
return;
|
||||
}
|
||||
// after approval = on sale at once: for the first version of an app that is the owner's step
|
||||
if (target.releaseType === 'AFTER_APPROVAL' && !enforceFirstRelease(ctx, kind, { store: 'apple', what: 'the first release of the app (it goes on sale by itself once approved)', consoleUrl: consoleUrl('asc.version') })) return;
|
||||
if (!(await confirm(ctx, `Set the release type of ${tag}: ${changeText(changes)}?`))) return;
|
||||
try {
|
||||
await c.asc('PATCH', `/v1/appStoreVersions/${v.id}`, { data: { type: 'appStoreVersions', id: v.id, attributes: Object.fromEntries(changes.map(ch => [ch.field, ch.to])) } });
|
||||
} catch (e) {
|
||||
if (/-> 409/.test((e as Error).message)) throw new Error(`${tag}: App Store Connect refuses the release type change now (409): ${(e as Error).message}
|
||||
It is editable until App Review approves the version; after that use apple release --manual-release.`);
|
||||
throw e;
|
||||
}
|
||||
result({ changed: true, changes });
|
||||
done(TAG, `${tag}: release type ${changeText(changes)}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
// is --version, else the one an open submission holds, else the editable one (Prepare for Submission or rejected).
|
||||
// Under `serve` (the desktop's owner action) the plan table is printed next to the --json result.
|
||||
import { sellsOn } from '../lib/config';
|
||||
import { changeText, RELEASE_TYPE_EDITABLE, releaseTypeChanges, wantedRelease } from '../lib/release-type';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { need } from '../lib/ctx';
|
||||
import { done, log, printJson, warn } from '../lib/log';
|
||||
|
|
@ -97,6 +98,10 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
|
|||
if (mode === 'submit' && !SUBMITTABLE_APP.has(stateOf(v)) && !held.has(v.id)) throw new Error(`App Store version ${String(v.attributes.versionString)} (${pl}) is ${stateOf(v)}: only a version in Prepare for Submission (or a rejected one) can be submitted`);
|
||||
const versionString = String(v.attributes.versionString);
|
||||
if (!a.has('json')) log(TAG, `app version ${versionString} (${pl}): ${stateOf(v)}`);
|
||||
// apple.releaseType: the version goes live as the config says (afterApproval | manual | scheduled); set before the submit when it differs
|
||||
const relWant = wantedRelease(config.apple);
|
||||
const relChanges = relWant ? releaseTypeChanges(v.attributes, relWant) : [];
|
||||
if (relChanges.length && !RELEASE_TYPE_EDITABLE.has(stateOf(v))) throw new Error(`App Store version ${versionString} (${pl}) is ${stateOf(v)}: its release type can no longer be changed (apple.releaseType wants ${changeText(relChanges)})`);
|
||||
|
||||
// the build, by its number
|
||||
const b = (await c.all(`/v1/builds?filter[app]=${appId}&filter[version]=${number}&filter[preReleaseVersion.version]=${versionString}&filter[preReleaseVersion.platform]=${platform}`))[0];
|
||||
|
|
@ -123,7 +128,7 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
|
|||
const gc = gcUse.inUse ? await readGcSwitch(c, appId, v) : null;
|
||||
const gcPlan = gc ? gcSwitchPlan(gc) : [];
|
||||
if (a.has('json')) {
|
||||
printJson({ dryRun: dry, releaseKind: kind.kind, gameCenter: gcPlan, version: { id: v.id, versionString, platform, state: stateOf(v) }, build, submission: state.choice ?? null, resolve: marks, rows, skipped: plan.skipped, blockers });
|
||||
printJson({ dryRun: dry, releaseKind: kind.kind, releaseType: relChanges.length ? { changes: relChanges } : null, gameCenter: gcPlan, version: { id: v.id, versionString, platform, state: stateOf(v) }, build, submission: state.choice ?? null, resolve: marks, rows, skipped: plan.skipped, blockers });
|
||||
// the desktop's owner dialog shows the output text: the plan table goes with the result there
|
||||
if (process.env.STOREBOUND_MACHINE === '1') { console.log(formatRows(rows)); for (const x of blockers) warn(TAG, x); }
|
||||
if (blockers.length) process.exitCode = 1;
|
||||
|
|
@ -132,6 +137,7 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
|
|||
} else {
|
||||
console.log(formatRows(rows));
|
||||
log(TAG, `${kind.kind === 'update' ? 'update of an app on sale' : 'FIRST release of this app (the owner approves it once: --first-release)'}: ${kind.why}`);
|
||||
if (relChanges.length) log(TAG, `${dry ? 'would set' : 'setting'} release type: ${changeText(relChanges)} (apple.releaseType)`);
|
||||
for (const l of gcPlan) log(TAG, `${dry ? 'would ' : ''}${l} (Game Center: ${gcUse.why})`);
|
||||
for (const s of plan.skipped) log(TAG, `not added ${s.id}: ${s.why}`);
|
||||
for (const x of blockers) warn(TAG, x);
|
||||
|
|
@ -149,6 +155,10 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
|
|||
await c.asc('PATCH', `/v1/appStoreVersions/${v.id}/relationships/build`, { data: { type: 'builds', id: build.id } });
|
||||
log(TAG, `build ${number} attached to ${versionString}`);
|
||||
}
|
||||
if (relChanges.length) {
|
||||
await c.asc('PATCH', `/v1/appStoreVersions/${v.id}`, { data: { type: 'appStoreVersions', id: v.id, attributes: Object.fromEntries(relChanges.map(ch => [ch.field, ch.to])) } });
|
||||
log(TAG, `release type of ${versionString}: ${changeText(relChanges)}`);
|
||||
}
|
||||
if (gc && gcPlan.length) await ensureGcSwitch(c, gc, TAG);
|
||||
try {
|
||||
await submitPlanned(ctx, c, { appId, ids, state, tag: TAG, withVersion: true, appVersionId: v.id, platform, quiet: true, resolve });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue