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;
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import { iphoneDuoNote } from './shots/spec';
|
|||
import { hitLine } from './lib/cross-store';
|
||||
import { findFastlaneTraces, traceMessage } from './lib/fastlane-traces';
|
||||
import { listingFileMentions } from './lib/listing-mentions';
|
||||
import { projectFamilies } from './lib/device-families';
|
||||
|
||||
/** The newest .ipa in a folder (apple build's export), or null. */
|
||||
function newestIpa(dir: string): string | null {
|
||||
|
|
@ -173,6 +174,14 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
note('shots.capture.auth signs in to a local stack with reviewDemoAccount, the account App Review gets from apple review-details: put the local demo sign-in in captureAuth in .storebound/credentials.json and use ${cred:<name>}'); }
|
||||
const duo = iphoneDuoNote({ apple: !!config.apple, duo: !!config.apple?.iphoneDuo, play: !!config.play });
|
||||
if (duo?.level === 'info') { hints.push(duo.msg); log('check', `hint: ${duo.msg}`); } else if (duo) note(duo.msg);
|
||||
// device families: offline only the project and the config can be compared; the live version's families need the store
|
||||
if (config.apple) {
|
||||
const proj = projectFamilies(ctx.loaded.root, config.apple.bundleId, config.apple.build?.project);
|
||||
const iosOnly = !proj || !proj.families.ipad;
|
||||
if (proj && proj.families.ipad && config.apple.ipad !== true) note(`${proj.file} builds for iPad (TARGETED_DEVICE_FAMILY ${proj.configurations.join(' / ')}) but apple.ipad is not true: iPad screenshots are not required or checked (set apple.ipad: true)`);
|
||||
else if (proj && !proj.families.ipad && config.apple.ipad === true) note(`apple.ipad is true but ${proj.file} builds TARGETED_DEVICE_FAMILY ${proj.configurations.join(' / ')} (no iPad): set "1,2" or apple.ipad: false`);
|
||||
else if (iosOnly && config.apple.ipad !== true) note(`device families: ${proj ? `iPhone only (apple.ipad not true, TARGETED_DEVICE_FAMILY ${proj.configurations.join(' / ')})` : 'no Xcode project found here'}, and the families of the live version are not known offline: if it supports iPad, Apple refuses an iPhone-only upload after the build (ITMS-90101). storebound validate and apple build compare with the store first`);
|
||||
}
|
||||
const creative = creativeNote(config, ctx.loaded.resolve(config.renders ?? config.shots?.out ?? 'store/renders'));
|
||||
if (creative) { hints.push(creative); log('check', `hint: ${creative}`); }
|
||||
// the scaffolder's own placeholders would reach the stores as the app's copyright, contact and legal pages
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
|||
'apple status': [() => import('./apple/status'), 'versions, builds, IAPs, review submissions (--wait-review, --cancel-submission <id>); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
|
||||
'apple creative-assets': [() => import('./apple/creative-assets'), 'Header and Search Results: product page header (21:9) and search results asset (3:2) per locale, or one universal 5244x2950 PNG; uploads to the Asset Library and places it once Apple approved it, reporting waiting ones per locale (MD5 upsert, safe to re-run); --add-for-review, --verify (read-only), --dry'],
|
||||
'apple previews': [() => import('./apple/previews'), 'app preview videos per locale (MD5-idempotent, poster frame time code); --dry'],
|
||||
'apple release': [() => import('./apple/release'), 'phased release on|pause|resume|complete, --manual-release (asks first; --yes for an update of an app on sale, exit 7 when already released; the first-ever release is exit 3 OWNER_STEP unless --first-release); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
|
||||
'apple release': [() => import('./apple/release'), 'phased release on|pause|resume|complete, --after-approval | --manual | --scheduled <date> (how an unapproved version goes live; exit 7 when already that type), --manual-release (asks first; --yes for an update of an app on sale, exit 7 when already released; the first-ever release is exit 3 OWNER_STEP unless --first-release); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
|
||||
'apple signing': [() => import('./apple/signing'), 'status | ensure: bundle id capabilities, distribution certificate (key kept in .storebound/signing), App Store profiles installed for Xcode; --dry, asks first'],
|
||||
'apple build': [() => import('./apple/build'), 'xcodebuild archive + exportArchive (ExportOptions.plist generated, next build number, API key signing); Capacitor, React Native, Flutter, Unity; --platform macos --tauri <src-tauri> [--path-free] builds a Tauri app for the Mac App Store (signed .app, sandbox entitlements, profile, productbuild .pkg); --dry'],
|
||||
'apple upload': [() => import('./apple/upload'), 'upload an IPA, or with --platform macos a signed .pkg (App Store Connect API build upload, or --via altool), build number checked, waits for processing; --dry, asks first'],
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@
|
|||
],
|
||||
"apple build": [
|
||||
"allow-app-group-gap",
|
||||
"allow-device-gap",
|
||||
"allow-ssh",
|
||||
"allow-stale-web",
|
||||
"allow-version-mismatch",
|
||||
|
|
@ -229,13 +230,16 @@
|
|||
"enable"
|
||||
],
|
||||
"apple release": [
|
||||
"after-approval",
|
||||
"env-file",
|
||||
"first-release",
|
||||
"manual",
|
||||
"manual-release",
|
||||
"phased",
|
||||
"platform",
|
||||
"progress",
|
||||
"result-file",
|
||||
"scheduled",
|
||||
"version",
|
||||
"version-from"
|
||||
],
|
||||
|
|
@ -1145,12 +1149,14 @@
|
|||
"accounts",
|
||||
"add-for-review",
|
||||
"adgroup",
|
||||
"after-approval",
|
||||
"against",
|
||||
"agent",
|
||||
"all",
|
||||
"all-pages",
|
||||
"allow-app-group-gap",
|
||||
"allow-availability-changes",
|
||||
"allow-device-gap",
|
||||
"allow-existing",
|
||||
"allow-price-changes",
|
||||
"allow-ssh",
|
||||
|
|
@ -1265,6 +1271,7 @@
|
|||
"locale",
|
||||
"locale-map",
|
||||
"lookup",
|
||||
"manual",
|
||||
"manual-release",
|
||||
"max-level",
|
||||
"max-ratio",
|
||||
|
|
@ -1348,6 +1355,7 @@
|
|||
"root",
|
||||
"scan-paths",
|
||||
"scenes",
|
||||
"scheduled",
|
||||
"scheme",
|
||||
"sdk-dirs",
|
||||
"search",
|
||||
|
|
@ -1526,7 +1534,8 @@
|
|||
],
|
||||
"apple release": [
|
||||
"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 --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]"
|
||||
],
|
||||
"apple resubmit": [
|
||||
"storebound apple resubmit --build <n> [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--first-release] [--dry] [--yes] [--json]"
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { type AppleSigning, validateSigning } from './signing';
|
|||
import { type AppleBuildConfig, validateAppleBuild } from './xcode';
|
||||
import { type PlayBuildConfig, validatePlayBuild } from './gradle';
|
||||
import { type OriginalityConfig, validateOriginality } from './originality';
|
||||
import { validateReleaseType } from './release-type';
|
||||
|
||||
/** consumable / non_consumable: both stores. non_renewing: Apple NON_RENEWING_SUBSCRIPTION (access for a fixed time,
|
||||
* you track the expiry); Google Play has no such type, so it becomes a one-time product your app consumes when the
|
||||
|
|
@ -678,6 +679,14 @@ export interface ShipConfig {
|
|||
screenshotTypes?: string[];
|
||||
/** The app runs on iPad: renders, checks and uploads 13" iPad screenshots (2064 x 2752) and iPad app previews. */
|
||||
ipad?: boolean;
|
||||
/** How an approved version goes live: "afterApproval" (released as soon as App Review approves it), "manual" (waits in
|
||||
* Pending Developer Release for `apple release --manual-release`) or "scheduled" (not before earliestReleaseDate).
|
||||
* Applied by `apple listing` when it creates a version and by `apple submit` before submitting (old -> new in the dry
|
||||
* run); `apple release --after-approval | --manual` changes it on an editable version. Unset: new versions are MANUAL
|
||||
* and existing ones are left as they are. */
|
||||
releaseType?: 'afterApproval' | 'manual' | 'scheduled';
|
||||
/** releaseType "scheduled": the earliest release, ISO 8601 date and time (2027-03-01T09:00:00Z). */
|
||||
earliestReleaseDate?: string;
|
||||
/**
|
||||
* Also render, check and upload a 6.3" iPhone set (Dynamic Island, medium display, 1206 x 2622 -> APP_IPHONE_61).
|
||||
* Off by default: App Store Connect lists a medium-display screenshot as required for iPhone but fills it by
|
||||
|
|
@ -1197,6 +1206,7 @@ export function validateConfig(c: unknown): string[] {
|
|||
if (an !== undefined && (typeof an !== 'string' || !an.trim())) e.push('apple.name: the App Store name (text), or leave it out to use app.name');
|
||||
else if (an !== undefined && [...an].length > APPLE_NAME_MAX) e.push(`apple.name: ${[...an].length} characters > ${APPLE_NAME_MAX} (App Store limit)`);
|
||||
if (cfg.apple.appId && !/^\d+$/.test(cfg.apple.appId)) e.push(`apple.appId: numeric Apple ID of the app (${consoleLink('asc.app-info')})`);
|
||||
e.push(...validateReleaseType(cfg.apple));
|
||||
const enc = cfg.apple.usesNonExemptEncryption;
|
||||
if (enc !== undefined && typeof enc !== 'boolean') e.push('apple.usesNonExemptEncryption: true | false');
|
||||
if (cfg.apple.screenshotTypes && !cfg.apple.screenshotTypes.every(t => /^APP_[A-Z0-9_]+$/.test(t))) e.push('apple.screenshotTypes: display types like APP_IPHONE_67');
|
||||
|
|
|
|||
111
cli/lib/device-families.ts
Normal file
111
cli/lib/device-families.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// Device families of an iOS app: what the last live version of the app on the App Store supports, against what the
|
||||
// project builds (TARGETED_DEVICE_FAMILY in the Xcode project) and what the config says (apple.ipad).
|
||||
// Apple refuses an upload that drops a device family the previous version supported: ITMS-90101 "This bundle does not
|
||||
// support one or more of the devices supported by the previous app version", after a full archive (seen live: an app
|
||||
// whose live version ran on iPad, built with TARGETED_DEVICE_FAMILY = 1). So `validate` and `apple build` compare first.
|
||||
// Pure helpers; the store reads are in cli/apple/device-families.ts.
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export interface Families { iphone: boolean; ipad: boolean }
|
||||
|
||||
/** Families in TARGETED_DEVICE_FAMILY: 1 iPhone/iPod, 2 iPad (3 tvOS, 4 watchOS, 6 Mac Catalyst, 7 Vision). Pure. */
|
||||
export function familiesOfList(list: number[]): Families { return { iphone: list.includes(1), ipad: list.includes(2) }; }
|
||||
|
||||
/** The families a store signal names: build bundle deviceProtocols, file-size device models, screenshot display types. Pure. */
|
||||
export function familiesOfStrings(values: string[]): Families {
|
||||
const t = values.join(' ');
|
||||
return { iphone: /iphone|ipod/i.test(t), ipad: /ipad/i.test(t) };
|
||||
}
|
||||
|
||||
export interface LiveFamilies { version: string; state: string; families: Families; evidence: string[] }
|
||||
|
||||
/** Merges the signals of one live version: any signal naming iPad means the version supports iPad. null when no signal
|
||||
* names any family (unknown, never "iPhone only"). Pure. */
|
||||
export function liveFamilies(version: string, state: string, signals: { name: string; values: string[] }[]): LiveFamilies | null {
|
||||
const evidence: string[] = [];
|
||||
const families: Families = { iphone: false, ipad: false };
|
||||
for (const s of signals) {
|
||||
const f = familiesOfStrings(s.values);
|
||||
if (!f.iphone && !f.ipad) continue;
|
||||
families.iphone ||= f.iphone;
|
||||
families.ipad ||= f.ipad;
|
||||
evidence.push(`${s.name}: ${[f.iphone && 'iPhone', f.ipad && 'iPad'].filter(Boolean).join(' + ')}`);
|
||||
}
|
||||
return evidence.length ? { version, state, families, evidence } : null;
|
||||
}
|
||||
|
||||
export interface ProjectFamilies { file: string; list: number[]; families: Families; configurations: string[] }
|
||||
|
||||
/** The Release build configurations of a pbxproj with their TARGETED_DEVICE_FAMILY: the ones of the app's bundle id,
|
||||
* else every Release configuration that is not an extension (bundle id below the app's). Pure. */
|
||||
export function pbxFamilies(text: string, bundleId: string, file = 'project.pbxproj'): ProjectFamilies | null {
|
||||
const blocks: { name: string; bundle: string | null; family: string | null; ext: boolean }[] = [];
|
||||
const re = /isa = XCBuildConfiguration;[^{]*?buildSettings = \{([\s\S]*?)\};\s*name = ("[^"]*"|[\w.-]+);/g;
|
||||
for (let m: RegExpExecArray | null; (m = re.exec(text));) {
|
||||
const body = m[1]!;
|
||||
const fam = /TARGETED_DEVICE_FAMILY = ("?)([\d, ]+)\1;/.exec(body)?.[2] ?? null;
|
||||
const bid = /PRODUCT_BUNDLE_IDENTIFIER = "?([^";\s]+)"?;/.exec(body)?.[1] ?? null;
|
||||
blocks.push({ name: m[2]!.replace(/"/g, ''), bundle: bid, family: fam, ext: /WRAPPER_EXTENSION = appex|APPLICATION_EXTENSION_API_ONLY = YES/.test(body) || (bid !== null && bid.startsWith(`${bundleId}.`)) });
|
||||
}
|
||||
const release = blocks.filter(b => b.name === 'Release' && b.family !== null);
|
||||
let pick = release.filter(b => b.bundle === bundleId);
|
||||
if (!pick.length) pick = release.filter(b => !b.ext);
|
||||
if (!pick.length) return null;
|
||||
const list = [...new Set(pick.flatMap(b => b.family!.split(',').map(x => Number(x.trim())).filter(n => Number.isFinite(n) && n > 0)))].sort((a, b) => a - b);
|
||||
return { file, list, families: familiesOfList(list), configurations: pick.map(b => b.family!.replace(/\s/g, '')) };
|
||||
}
|
||||
|
||||
const SKIP = new Set(['node_modules', 'Pods', '.git', 'build', 'DerivedData', 'Carthage', '.build', 'dist', 'vendor']);
|
||||
|
||||
/** Every project.pbxproj under root (depth <= 5), root-relative with forward slashes. */
|
||||
export function findPbxprojs(root: string, depth = 5): string[] {
|
||||
const out: string[] = [];
|
||||
const walk = (dir: string, rel: string, d: number): void => {
|
||||
if (d > depth || !existsSync(dir)) return;
|
||||
let names: import('node:fs').Dirent[];
|
||||
try { names = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
||||
for (const e of names) {
|
||||
if (!e.isDirectory() || SKIP.has(e.name)) continue;
|
||||
const r = rel ? `${rel}/${e.name}` : e.name;
|
||||
if (e.name.endsWith('.xcodeproj')) { if (existsSync(join(dir, e.name, 'project.pbxproj'))) out.push(`${r}/project.pbxproj`); continue; }
|
||||
walk(join(dir, e.name), r, d + 1);
|
||||
}
|
||||
};
|
||||
walk(root, '', 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The project's device families, read from the Xcode project (apple.build.project when set, else the first project
|
||||
* that has the app's configurations). null when the project is not on this machine or sets no families. */
|
||||
export function projectFamilies(root: string, bundleId: string, project?: string): ProjectFamilies | null {
|
||||
const files = project && project.endsWith('.xcodeproj') ? [`${project.replace(/\\/g, '/')}/project.pbxproj`] : findPbxprojs(root);
|
||||
for (const f of files) {
|
||||
try {
|
||||
const r = pbxFamilies(readFileSync(join(root, f), 'utf8'), bundleId, f);
|
||||
if (r) return r;
|
||||
} catch { /* unreadable: next */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface FamilyVerdict { level: 'ok' | 'blocker' | 'warn'; msg: string; fix?: string }
|
||||
|
||||
/** Compares the live version, the project and the config. Blocker: the live version supports iPad and the project does
|
||||
* not (the upload is refused with ITMS-90101) or apple.ipad is not true (iPad screenshots and checks are off). Pure. */
|
||||
export function familyVerdicts(i: { live: LiveFamilies | null; project: ProjectFamilies | null; ipad: boolean | undefined }): FamilyVerdict[] {
|
||||
const out: FamilyVerdict[] = [];
|
||||
const { live, project } = i;
|
||||
const fixProject = 'set TARGETED_DEVICE_FAMILY to "1,2" (Xcode: target > General > Supported Destinations: add iPad)';
|
||||
if (!live) {
|
||||
out.push({ level: 'warn', msg: `device families of the live version not known${project ? '' : ' and no Xcode project found'}: if it supports iPad, an iPhone-only build is refused at upload (ITMS-90101)`, fix: 'storebound validate (reads the live version from App Store Connect)' });
|
||||
return out;
|
||||
}
|
||||
const liveText = `${[live.families.iphone && 'iPhone', live.families.ipad && 'iPad'].filter(Boolean).join(' + ')} (version ${live.version} ${live.state}; ${live.evidence.join('; ')})`;
|
||||
if (live.families.ipad) {
|
||||
if (project && !project.families.ipad) out.push({ level: 'blocker', msg: `the live version supports ${liveText}, but ${project.file} builds TARGETED_DEVICE_FAMILY = ${project.configurations.join(' / ')} (no iPad): Apple refuses the upload after the archive with ITMS-90101`, fix: `${fixProject}; and apple.ipad: true in the config` });
|
||||
if (i.ipad !== true) out.push({ level: 'blocker', msg: `the live version supports ${liveText}, but apple.ipad is not true in the config`, fix: 'set apple.ipad: true (iPad screenshots are then required) and TARGETED_DEVICE_FAMILY "1,2"' });
|
||||
if (!out.length) out.push({ level: 'ok', msg: `device families: live ${liveText}; project ${project ? `TARGETED_DEVICE_FAMILY ${project.configurations.join(' / ')}` : 'not read'}; apple.ipad true` });
|
||||
} else out.push({ level: 'ok', msg: `device families: live ${liveText}; nothing to keep` });
|
||||
return out;
|
||||
}
|
||||
|
|
@ -69,7 +69,7 @@ export const versionInfos = (rows: { id: string; attributes: Record<string, unkn
|
|||
* string changes only while PREPARE_FOR_SUBMISSION), create a new one, or refuse (the number exists, is not higher than
|
||||
* a version that went through review, or another version is in review / pending release). Pure.
|
||||
*/
|
||||
export function planVersion(rows: { id: string; attributes: Record<string, unknown> }[], want: string, attrs: { copyright: string; releaseType: string }, platform = 'IOS'): VersionPlan {
|
||||
export function planVersion(rows: { id: string; attributes: Record<string, unknown> }[], want: string, attrs: { copyright: string; releaseType?: string; earliestReleaseDate?: string }, platform = 'IOS'): VersionPlan {
|
||||
const existing = versionInfos(rows);
|
||||
const editable = existing.find(v => EDITABLE_STATES.has(v.state));
|
||||
const others = existing.filter(v => v !== editable);
|
||||
|
|
@ -87,7 +87,8 @@ export function planVersion(rows: { id: string; attributes: Record<string, unkno
|
|||
return refuse(`version ${want} is not higher than ${higher.versionString} (${higher.state}); App Store versions only go up`);
|
||||
if (editable) {
|
||||
const cur = rows.find(r => r.id === editable.id)!.attributes;
|
||||
const wantAttrs = { versionString: want, copyright: attrs.copyright, releaseType: attrs.releaseType };
|
||||
// releaseType only when asked for (apple.releaseType): an existing version keeps the one it has
|
||||
const wantAttrs: Record<string, string | undefined> = { versionString: want, copyright: attrs.copyright, releaseType: attrs.releaseType, earliestReleaseDate: attrs.releaseType === 'SCHEDULED' ? attrs.earliestReleaseDate : undefined };
|
||||
const p = planItem(editable.id, { id: editable.id, attributes: cur }, wantAttrs);
|
||||
if (editable.versionString !== want && editable.state !== 'PREPARE_FOR_SUBMISSION')
|
||||
return refuse(`the editable version ${editable.versionString} is ${editable.state}; its version string changes only while PREPARE_FOR_SUBMISSION (use --version ${editable.versionString}, or change it in App Store Connect)`);
|
||||
|
|
@ -102,7 +103,8 @@ export function planVersion(rows: { id: string; attributes: Record<string, unkno
|
|||
if (busy) return refuse(`version ${busy.versionString} is ${busy.state}; App Store Connect allows one version in progress (${busy.state === 'PENDING_DEVELOPER_RELEASE'
|
||||
? `release it first: storebound apple release --manual-release --version ${busy.versionString} --dry, then without --dry`
|
||||
: busy.state === 'WAITING_FOR_REVIEW' || busy.state === 'IN_REVIEW' ? 'wait for the review, or remove it from review in App Store Connect' : 'wait for its release or remove it from review'})`);
|
||||
const attributes = { platform, versionString: want, copyright: attrs.copyright, releaseType: attrs.releaseType };
|
||||
const attributes: Record<string, string> = { platform, versionString: want, copyright: attrs.copyright, releaseType: attrs.releaseType ?? 'MANUAL' };
|
||||
if (attributes.releaseType === 'SCHEDULED' && attrs.earliestReleaseDate) attributes.earliestReleaseDate = attrs.earliestReleaseDate;
|
||||
return { ...base, action: 'create', reason: `create version ${want} (no editable ${PLATFORM_LABEL[platform] ?? platform} version)`, changes: Object.entries(attributes).map(([field, to]) => ({ field, from: null, to })), attributes };
|
||||
}
|
||||
|
||||
|
|
|
|||
54
cli/lib/release-type.ts
Normal file
54
cli/lib/release-type.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// How an App Store version goes live after App Review: apple.releaseType in the config, appStoreVersions.releaseType
|
||||
// on the store. MANUAL leaves an approved version in PENDING_DEVELOPER_RELEASE until someone releases it; AFTER_APPROVAL
|
||||
// releases it as soon as it is approved; SCHEDULED releases it at earliestReleaseDate at the earliest (Apple:
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/appstoreversion/attributes, releaseType, earliestReleaseDate).
|
||||
// Pure: the commands (apple listing, apple submit, apple release) read the version and send the PATCH.
|
||||
import type { FieldDiff } from './listing-plan';
|
||||
|
||||
export const RELEASE_TYPES = ['afterApproval', 'manual', 'scheduled'] as const;
|
||||
export type ReleaseTypeName = (typeof RELEASE_TYPES)[number];
|
||||
export type AscReleaseType = 'AFTER_APPROVAL' | 'MANUAL' | 'SCHEDULED';
|
||||
|
||||
export const ASC_RELEASE_TYPE: Record<ReleaseTypeName, AscReleaseType> = { afterApproval: 'AFTER_APPROVAL', manual: 'MANUAL', scheduled: 'SCHEDULED' };
|
||||
|
||||
/** Version states in which App Store Connect still takes a release type change: everything before approval.
|
||||
* Once the version is approved (PENDING_DEVELOPER_RELEASE and later) it is released with `apple release --manual-release`. */
|
||||
export const RELEASE_TYPE_EDITABLE = new Set(['PREPARE_FOR_SUBMISSION', 'DEVELOPER_REJECTED', 'REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY',
|
||||
'READY_FOR_REVIEW', 'WAITING_FOR_REVIEW', 'IN_REVIEW']);
|
||||
|
||||
export interface WantedRelease { releaseType: AscReleaseType; earliestReleaseDate?: string }
|
||||
|
||||
/** The release type apple.releaseType asks for, or null when the config does not say (then nothing is touched). */
|
||||
export function wantedRelease(apple: { releaseType?: ReleaseTypeName; earliestReleaseDate?: string } | undefined): WantedRelease | null {
|
||||
if (!apple?.releaseType) return null;
|
||||
const releaseType = ASC_RELEASE_TYPE[apple.releaseType];
|
||||
return releaseType === 'SCHEDULED' && apple.earliestReleaseDate ? { releaseType, earliestReleaseDate: apple.earliestReleaseDate } : { releaseType };
|
||||
}
|
||||
|
||||
/** Problems with apple.releaseType / apple.earliestReleaseDate. Pure. */
|
||||
export function validateReleaseType(a: { releaseType?: unknown; earliestReleaseDate?: unknown } | undefined): string[] {
|
||||
if (!a) return [];
|
||||
const e: string[] = [];
|
||||
if (a.releaseType !== undefined && !(RELEASE_TYPES as readonly unknown[]).includes(a.releaseType)) e.push(`apple.releaseType: ${RELEASE_TYPES.join(' | ')} (afterApproval releases an approved version at once; manual waits for \`apple release --manual-release\`)`);
|
||||
if (a.earliestReleaseDate !== undefined) {
|
||||
if (typeof a.earliestReleaseDate !== 'string' || Number.isNaN(Date.parse(a.earliestReleaseDate))) e.push('apple.earliestReleaseDate: an ISO 8601 date and time, e.g. 2027-03-01T09:00:00Z');
|
||||
else if (a.releaseType !== 'scheduled') e.push('apple.earliestReleaseDate: only with apple.releaseType "scheduled"');
|
||||
}
|
||||
if (a.releaseType === 'scheduled' && a.earliestReleaseDate === undefined) e.push('apple.releaseType "scheduled": apple.earliestReleaseDate is required (ISO 8601 date and time)');
|
||||
return e;
|
||||
}
|
||||
|
||||
/** The attribute changes that bring a version to `want` (old -> new), [] when it is there already. A scheduled date is
|
||||
* compared as an instant. Pure. */
|
||||
export function releaseTypeChanges(cur: Record<string, unknown>, want: WantedRelease): FieldDiff[] {
|
||||
const out: FieldDiff[] = [];
|
||||
const have = cur.releaseType == null ? null : String(cur.releaseType);
|
||||
if (have !== want.releaseType) out.push({ field: 'releaseType', from: have, to: want.releaseType });
|
||||
if (want.releaseType === 'SCHEDULED' && want.earliestReleaseDate) {
|
||||
const had = cur.earliestReleaseDate == null ? null : String(cur.earliestReleaseDate);
|
||||
if (had === null || Date.parse(had) !== Date.parse(want.earliestReleaseDate)) out.push({ field: 'earliestReleaseDate', from: had, to: want.earliestReleaseDate });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const changeText = (c: FieldDiff[]): string => c.map(x => `${x.field} ${x.from ?? '(none)'} -> ${x.to}`).join(', ');
|
||||
|
|
@ -320,3 +320,22 @@ export function betaDescriptions(t: TfConfig | undefined, langs: { id: string; a
|
|||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- invitations need an installable build ----
|
||||
|
||||
/** Whether a build can be installed by the testers of a group: valid, not expired, and past Beta App Review for an
|
||||
* external group (externalBuildState BETA_APPROVED / IN_BETA_TESTING), ready for internal testing for an internal one
|
||||
* (internalBuildState READY_FOR_BETA_TESTING / IN_BETA_TESTING). `detail`: the build's buildBetaDetails attributes. Pure. */
|
||||
export function installableBuild(build: { attributes: Record<string, unknown> }, detail: Record<string, unknown> | null | undefined, internal: boolean): boolean {
|
||||
if (build.attributes.expired === true) return false;
|
||||
if (build.attributes.processingState !== undefined && build.attributes.processingState !== 'VALID') return false;
|
||||
const state = String((internal ? detail?.internalBuildState : detail?.externalBuildState) ?? '');
|
||||
return internal ? ['READY_FOR_BETA_TESTING', 'IN_BETA_TESTING'].includes(state) : ['BETA_APPROVED', 'IN_BETA_TESTING'].includes(state);
|
||||
}
|
||||
|
||||
/** The line for testers that are added but cannot be invited yet. Pure. */
|
||||
export const noInstallableBuildHint = (groups: string[]): string =>
|
||||
`tester added; the invite goes out when a build in ${groups.join(', ')} passes Beta App Review (re-run with --invite then)`;
|
||||
|
||||
/** Apple's 409 STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS on POST /v1/betaTesterInvitations (seen live). Pure. */
|
||||
export const noInstallableBuilds409 = (e: unknown): boolean => /NO_INSTALLABLE_BUILDS/.test(String((e as Error)?.message ?? e));
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ export interface AppleSnapshot {
|
|||
/** Screenshots of the version that are not COMPLETE (cli/apple/screenshot-health.ts): a submit answers 409
|
||||
* ASSET_IN_POST_PROCESSING while any is left. */
|
||||
screenshotsPending?: Pending[];
|
||||
/** Live version's device families vs the Xcode project and apple.ipad (cli/lib/device-families.ts familyVerdicts); undefined: not iOS. */
|
||||
deviceFamilies?: { level: 'ok' | 'blocker' | 'warn'; msg: string; fix?: string }[];
|
||||
/** Editable or rejected versions of the app's other platforms (an app's macOS version has its own state). */
|
||||
otherPlatformVersions?: { versionString: string; state: string; platform: string }[];
|
||||
/** app.copyright from the config (undefined: not set). */
|
||||
|
|
@ -215,6 +217,7 @@ export function appleRules(s: AppleSnapshot): Finding[] {
|
|||
else if (EDITABLE.includes(s.version.state)) add('ok', 'version', `version ${vl}: ${s.version.state}`);
|
||||
else if (IN_FLIGHT.includes(s.version.state)) add('warn', 'version', `version ${vl} is ${s.version.state} (nothing to prepare)`);
|
||||
else add('warn', 'version', `version ${vl}: ${s.version.state}`);
|
||||
for (const d of s.deviceFamilies ?? []) add(d.level, 'device-families', d.msg, d.fix);
|
||||
{
|
||||
const pend = s.screenshotsPending ?? [];
|
||||
const lines = describePending(pend);
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ import { playClient } from './play/client';
|
|||
import { listOneTimeProducts } from './play/products';
|
||||
import { creativeNote } from './shots/creative';
|
||||
import { creativeWaiting, localCreative } from './apple/creative-assets';
|
||||
import { liveDeviceFamilies } from './apple/device-families';
|
||||
import { type FamilyVerdict, familyVerdicts, projectFamilies } from './lib/device-families';
|
||||
import { fixFor, GUIDELINE, hitLine } from './lib/cross-store';
|
||||
import { listingFileMentions } from './lib/listing-mentions';
|
||||
import { brokerConfig, brokerPlayInfo, enabledApisOf, grantsOf, type PlayNeed, playGaps, sdkConfigGaps } from './lib/broker';
|
||||
|
|
@ -98,6 +100,14 @@ async function creativeWaitingOf(ctx: Ctx, c: Awaited<ReturnType<typeof ascClien
|
|||
} catch { return []; }
|
||||
}
|
||||
|
||||
/** Live version's device families against the project and apple.ipad (cli/lib/device-families.ts). A failed read is a warning, never a blocker. */
|
||||
async function deviceFamilyVerdicts(ctx: Ctx, c: Awaited<ReturnType<typeof ascClient>>, appId: string): Promise<FamilyVerdict[]> {
|
||||
const { config, root } = ctx.loaded;
|
||||
try {
|
||||
return familyVerdicts({ live: await liveDeviceFamilies(c, appId), project: projectFamilies(root, config.apple!.bundleId, config.apple?.build?.project), ipad: config.apple?.ipad });
|
||||
} catch (e) { return [{ level: 'warn', msg: `device families not compared: ${(e as Error).message.slice(0, 200)}` }]; }
|
||||
}
|
||||
|
||||
export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
|
||||
|
|
@ -173,6 +183,7 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
|
|||
creativeWaiting: await creativeWaitingOf(ctx, c, appId),
|
||||
screenshotsPending: pendingScreenshots(shotRows, updateLedger(root, shotRows), Date.now()),
|
||||
configCopyright: config.app.copyright,
|
||||
deviceFamilies: platform === 'IOS' ? await deviceFamilyVerdicts(ctx, c, appId) : undefined,
|
||||
build, locales, reviewDetail, ageRating,
|
||||
expectedLocales: config.languages.map(l => l.apple).filter((x): x is string => !!x),
|
||||
requiredDisplayTypes: requiredDisplayTypes(config.apple),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue