diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a1d003..cf58150 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## Unreleased +- Release type. `apple.releaseType: afterApproval | manual | scheduled` (+ `apple.earliestReleaseDate`): `apple listing` creates versions with it (MANUAL when unset, existing versions untouched) and `apple submit` / `resubmit` set it before submitting when it differs, the dry run showing old -> new. New `apple release --after-approval | --manual | --scheduled [--platform] [--version] [--first-release] --yes` changes it on a version App Review has not approved yet (clear refusal otherwise, with `--manual-release` as the way out); exit 7 when it already has that type; an update is an agent step, after-approval on the first version of an app is exit 3 OWNER_STEP. Pitfall 125. +- Device families. `validate` and `apple build` read the device families of the last live version (build bundle `deviceProtocols`, device models, iPad screenshot sets) and compare them with TARGETED_DEVICE_FAMILY in the Xcode project and `apple.ipad`; a gap stops `apple build` before the archive with the exact fix (ITMS-90101 otherwise comes after the build; `--allow-device-gap` overrides). Offline `check` warns when the live families are unknown or the config and project disagree. Pitfall 126. +- TestFlight. `apple testflight testers --invite` checks that the tester's group has an installable build (past Beta App Review for external groups); without one it adds the tester, skips the invite and prints when it can go out (exit 0). Apple's 409 NO_INSTALLABLE_BUILDS is handled the same way; `distribute --submit-review` prints the hint for testers not invited yet. Pitfall 127. + ## 0.8.55 ยท 2026-10-26 - `apple build --path-free`: a checkout in a shared build folder outside the home folder is no longer a leak needle (dsymutil and the module cache record it; only home and user-name paths count). diff --git a/cli/apple/build.ts b/cli/apple/build.ts index 87fa428..13e8a89 100644 --- a/cli/apple/build.ts +++ b/cli/apple/build.ts @@ -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 { if (build && !/^\d+(\.\d+){0,2}$/.test(build)) throw new Error('--build-number: digits, e.g. 42 or 1.0.42'); const buildNo = build ?? ''; + // 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 = {}; @@ -232,7 +246,7 @@ export async function run(ctx: Ctx): Promise { ]; const unlockPreview = unlock ? unlockSteps(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 }); diff --git a/cli/apple/device-families.ts b/cli/apple/device-families.ts new file mode 100644 index 0000000..2ae18c2 --- /dev/null +++ b/cli/apple/device-families.ts @@ -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 { + 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); +} diff --git a/cli/apple/listing.ts b/cli/apple/listing.ts index 8fdeb45..698b799 100644 --- a/cli/apple/listing.ts +++ b/cli/apple/listing.ts @@ -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'); diff --git a/cli/apple/release.ts b/cli/apple/release.ts index ab0f77c..aad0498 100644 --- a/cli/apple/release.ts +++ b/cli/apple/release.ts @@ -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 [--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 { 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 [--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 { } 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>, appId: string, name: 'afterApproval' | 'manual' | 'scheduled'): Promise { + 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): 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)}`); +} diff --git a/cli/apple/resubmit.ts b/cli/apple/resubmit.ts index 5bed343..66d1944 100644 --- a/cli/apple/resubmit.ts +++ b/cli/apple/resubmit.ts @@ -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 }); diff --git a/cli/apple/testflight.ts b/cli/apple/testflight.ts index 63c272c..8f70951 100644 --- a/cli/apple/testflight.ts +++ b/cli/apple/testflight.ts @@ -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 { + 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 { const { config, root, resolve } = ctx.loaded; const a = ctx.args; @@ -193,9 +204,35 @@ export async function run(ctx: Ctx): Promise { 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(); + const ready: { t: J; groups: string[] }[] = [], waiting = new Set(); + 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 { 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; diff --git a/cli/check.ts b/cli/check.ts index 89ddd21..692967e 100644 --- a/cli/check.ts +++ b/cli/check.ts @@ -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 { 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:}'); } 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 diff --git a/cli/commands.ts b/cli/commands.ts index 90df327..6e05b40 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -89,7 +89,7 @@ export const COMMANDS: Record Promise, string]> = { 'apple status': [() => import('./apple/status'), 'versions, builds, IAPs, review submissions (--wait-review, --cancel-submission ); --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 (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 [--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'], diff --git a/cli/data/flags.json b/cli/data/flags.json index 9493636..aa55fc7 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -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 [--version 1.2.0] [--first-release] [--yes] [--json]" ], "apple resubmit": [ "storebound apple resubmit --build [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--first-release] [--dry] [--yes] [--json]" diff --git a/cli/lib/config.ts b/cli/lib/config.ts index 6fb69c8..cda074e 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -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'); diff --git a/cli/lib/device-families.ts b/cli/lib/device-families.ts new file mode 100644 index 0000000..0630f22 --- /dev/null +++ b/cli/lib/device-families.ts @@ -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; +} diff --git a/cli/lib/listing-plan.ts b/cli/lib/listing-plan.ts index 7e18bed..40edf0c 100644 --- a/cli/lib/listing-plan.ts +++ b/cli/lib/listing-plan.ts @@ -69,7 +69,7 @@ export const versionInfos = (rows: { id: string; attributes: Record }[], want: string, attrs: { copyright: string; releaseType: string }, platform = 'IOS'): VersionPlan { +export function planVersion(rows: { id: string; attributes: Record }[], 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 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 = { 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 = { 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 }; } diff --git a/cli/lib/release-type.ts b/cli/lib/release-type.ts new file mode 100644 index 0000000..f090f07 --- /dev/null +++ b/cli/lib/release-type.ts @@ -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 = { 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, 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(', '); diff --git a/cli/lib/testflight.ts b/cli/lib/testflight.ts index 31381d7..41c0639 100644 --- a/cli/lib/testflight.ts +++ b/cli/lib/testflight.ts @@ -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 }, detail: Record | 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)); diff --git a/cli/lib/validate-rules.ts b/cli/lib/validate-rules.ts index 8470651..43d8906 100644 --- a/cli/lib/validate-rules.ts +++ b/cli/lib/validate-rules.ts @@ -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); diff --git a/cli/validate.ts b/cli/validate.ts index c91fdac..539f453 100644 --- a/cli/validate.ts +++ b/cli/validate.ts @@ -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>, appId: string): Promise { + 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 { 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 { 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), diff --git a/skills/appstore-first-release/SKILL.md b/skills/appstore-first-release/SKILL.md index edb266a..4f90081 100644 --- a/skills/appstore-first-release/SKILL.md +++ b/skills/appstore-first-release/SKILL.md @@ -76,7 +76,7 @@ answers the compliance facts no file proves, all in one batch at the start. 14. **Submit.** `storebound apple submit --build ` (dry run: the plan table with the build you uploaded, by its number, the version and the ready IAPs in one review submission), then the owner's yes for the same command with `--yes`; with IAPs `apple iap-submit --with-version --yes` does the same without attaching a build (step 11). In - the desktop app the owner presses Releases > Submit for review (plan, then a system dialog). Release type MANUAL + the desktop app the owner presses Releases > Submit for review (plan, then a system dialog). Release type MANUAL (or apple.releaseType: afterApproval | manual | scheduled; `apple release --after-approval | --manual | --scheduled ` changes it until the version is approved; for the first version of an app after-approval is the owner's step) means the owner presses "Release" after approval. 15. **Verify + wait.** `storebound apple status`: version WAITING_FOR_REVIEW, IAPs WAITING_FOR_REVIEW, one review submission. `storebound apple status --wait-review` polls until approved (exit 0) or rejected (exit 1, diff --git a/skills/release-management/SKILL.md b/skills/release-management/SKILL.md index 21bf298..34f7a7f 100644 --- a/skills/release-management/SKILL.md +++ b/skills/release-management/SKILL.md @@ -35,7 +35,12 @@ presses release; you prepare, dry-run and report. `storebound mcp` deliberately already); the first-ever submission is the owner's (exit 3 `OWNER_STEP`, re-run with `--first-release` after the owner's yes). Wait: `storebound apple status --wait-review` (polls with backoff, exit 0 approved, 1 rejected, 7 timeout; `--timeout 72h`). Rejected: `review-rejections` skill. -4. Approved = PENDING_DEVELOPER_RELEASE. Release an update yourself: `storebound apple release --manual-release --yes` +4. Release type: an update that should go live by itself is `afterApproval`. Set `apple.releaseType: afterApproval` in the + config (`apple listing` applies it to a new version, `apple submit` sets it before submitting and its dry run shows + `releaseType MANUAL -> AFTER_APPROVAL`), or on one version `storebound apple release --after-approval --yes` + (`--manual` / `--scheduled ` are the other two; allowed until App Review approves the version, exit 7 when it + is that type already; pitfall 125). Without it an approved MANUAL version waits in PENDING_DEVELOPER_RELEASE. + Approved = PENDING_DEVELOPER_RELEASE. Release an update yourself: `storebound apple release --manual-release --yes` (the first-ever release of an app is the owner's: `OWNER_STEP`) or the "Release This Version" button. With phased release on, automatic updates reach 1 %, 2 %, 5 %, 10 %, 20 %, 50 %, 100 % over 7 days; anyone can still update manually from the store. 5. During the rollout watch crashes / reviews. Problem: `storebound apple release --phased pause` (up to 30 days in diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index aa40e01..578902c 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1547,3 +1547,41 @@ that fails without the fix (see CONTRIBUTING.md). are not the user's and are not hits. A prebuilt vendored framework carries its own paths: rebuild it path-free. - **Never:** point a shared build folder at the default DerivedData, or run the privacy scan only after the upload. - **Guard:** `cli/lib/path-free.ts`, `tests/path-free.test.ts`, the hint when `--out` is outside the home folder. + +## 125. Updates sat in Pending Developer Release: the version was created and submitted with release type MANUAL + +- **Symptom:** an approved update did not go live; it waited in PENDING_DEVELOPER_RELEASE because `apple listing` created the + version with releaseType MANUAL and `apple submit` never touched it. +- **Do:** set `apple.releaseType: afterApproval` (or `manual` / `scheduled` with `apple.earliestReleaseDate`). `apple listing` + creates new versions with it, `apple submit` / `apple resubmit` set it on the version before submitting when it differs + (the dry run shows `releaseType MANUAL -> AFTER_APPROVAL`). On one version: `storebound apple release --after-approval --yes` + (`--manual`, `--scheduled `; `--platform`, `--version`, `--json`; exit 7 when it already has that type). The type + is editable until App Review approves the version; an approved one is released with `apple release --manual-release`. + Unset: new versions are MANUAL and existing ones are left as they are. Updates are agent steps; `--after-approval` on the + first version of an app puts it on sale by itself, so that is the owner's (exit 3 `OWNER_STEP`, `--first-release`). +- **Never:** wait for an approval email and then look for the console button, or flip the type back with `apple listing` runs. +- **Guard:** `cli/lib/release-type.ts`, `tests/apple-release-gaps.test.ts`. + +## 126. iPhone-only build of an app whose live version runs on iPad: ITMS-90101 after a full archive + +- **Symptom:** `storebound check` passed with `apple.ipad` false; the upload was refused after the archive with ITMS-90101 "This + bundle does not support one or more of the devices supported by the previous app version" (TARGETED_DEVICE_FAMILY = 1). +- **Do:** `storebound validate` and `apple build` read the live version (READY_FOR_SALE, else the newest that was) from App + Store Connect: its build bundle (`deviceProtocols`, device models of `buildBundleFileSizes`) and its screenshot sets + (`APP_IPAD_*`); any iPad signal means iPad. They compare it with TARGETED_DEVICE_FAMILY of the Release configuration in the + Xcode project and with `apple.ipad`, and stop BEFORE the archive with the fix: `TARGETED_DEVICE_FAMILY = "1,2"` and + `apple.ipad: true`. Offline `check` warns when the live families cannot be known. `--allow-device-gap` builds anyway. +- **Never:** drop a device family the live version supports, or turn `apple.ipad` off to silence the screenshot check. +- **Guard:** `cli/lib/device-families.ts`, `cli/apple/device-families.ts`, `tests/apple-release-gaps.test.ts`. The build bundle + attribute names come from Apple's spec; which signal carries the iPad answer live is not recorded, hence the merge. + +## 127. TestFlight invite on a new external group: 409 STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS + +- **Symptom:** `apple testflight testers --group --invite --yes` created the tester but the invitation failed with 409 + NO_INSTALLABLE_BUILDS: the group had no build past Beta App Review yet. +- **Do:** `--invite` first checks that a build of the tester's group is installable (external: BETA_APPROVED / IN_BETA_TESTING; + internal: READY_FOR_BETA_TESTING / IN_BETA_TESTING). If not, the tester stays added, no invitation is sent and the run prints + "tester added; the invite goes out when a build in passes Beta App Review (re-run with --invite then)", exit 0. The + 409 itself is treated the same way. `apple testflight distribute --submit-review` prints the line for testers not invited yet. +- **Never:** retry the invitation in a loop, or report the added tester as a failure. +- **Guard:** `installableBuild` / `noInstallableBuildHint` in `cli/lib/testflight.ts`, `tests/apple-release-gaps.test.ts`. diff --git a/skills/testflight/SKILL.md b/skills/testflight/SKILL.md index 42aadae..f076722 100644 --- a/skills/testflight/SKILL.md +++ b/skills/testflight/SKILL.md @@ -39,7 +39,10 @@ changes or deletes a group. commits; storebound masks them in every log line (`j***@example.com`). - `storebound apple testflight testers --dry` shows: new testers, testers added to more groups, (with `--remove`) testers who leave a managed group. The real run **asks first**: Apple emails each new tester an invitation once the - group has a build. `--invite` (re)sends invitations to testers still `NOT_INVITED` / `INVITED` (asks again). + group has a build. `--invite` (re)sends invitations to testers still `NOT_INVITED` / `INVITED` (asks again) once a build of the tester's group is + installable (an external group: past Beta App Review). Without one the tester is added, the invite is skipped and the run + says "tester added; the invite goes out when a build in passes Beta App Review (re-run with --invite then)" (exit 0; + Apple's 409 `NO_INSTALLABLE_BUILDS` is handled the same way; `distribute --submit-review` prints the same line; pitfall 127). - Internal groups only take App Store Connect users (Users and Access); add people there first. - Testers are an owner action: not an MCP tool. An agent prepares the CSV format and the dry run, the owner runs it. diff --git a/tests/apple-release-gaps.test.ts b/tests/apple-release-gaps.test.ts new file mode 100644 index 0000000..14fd382 --- /dev/null +++ b/tests/apple-release-gaps.test.ts @@ -0,0 +1,405 @@ +// Three gaps found in real use, against the stateful App Store Connect double (tests/helpers/fake-stores.ts): +// 1. release type: updates sat in Pending Developer Release because versions were created / submitted as MANUAL +// (apple.releaseType, apple submit, apple release --after-approval | --manual) +// 2. device families: an iPhone-only build of an app whose live version runs on iPad is refused AFTER the archive (ITMS-90101) +// 3. TestFlight: inviting a tester of a group without an installable build is 409 NO_INSTALLABLE_BUILDS +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, setDefaultTimeout, test } from 'bun:test'; +import { parseArgs } from '../cli/lib/args'; +import { runShipStep } from '../cli/lib/ship-step'; +import { run as appleSubmit } from '../cli/apple/submit'; +import { run as appleRelease } from '../cli/apple/release'; +import { run as appleTestflight } from '../cli/apple/testflight'; +import { run as appleBuild } from '../cli/apple/build'; +import { validateConfig } from '../cli/lib/config'; +import { planVersion } from '../cli/lib/listing-plan'; +import { releaseTypeChanges, validateReleaseType, wantedRelease } from '../cli/lib/release-type'; +import { familyVerdicts, liveFamilies, pbxFamilies, projectFamilies } from '../cli/lib/device-families'; +import { liveDeviceFamilies } from '../cli/apple/device-families'; +import { appleRules } from '../cli/lib/validate-rules'; +import { installableBuild, noInstallableBuildHint, noInstallableBuilds409 } from '../cli/lib/testflight'; +import { ascEnv, BASE, capture, project, restore } from './helpers/store-mock'; +import { installStores, type Stores, uninstallStores, writes } from './helpers/fake-stores'; + +const APP = '6400000101'; + +/** Runs a ship step in-process with stdout / stderr captured. */ +async function ship(key: string, argv: string[], body: (a: ReturnType) => Promise): Promise<{ code: number; out: string; err: string }> { + const out: string[] = [], err: string[] = []; + const o = process.stdout.write, e = process.stderr.write, cl = console.log, ce = console.error, cw = console.warn, ci = console.info; + process.stdout.write = ((s: string | Uint8Array) => { out.push(typeof s === 'string' ? s : new TextDecoder().decode(s)); return true; }) as typeof process.stdout.write; + process.stderr.write = ((s: string | Uint8Array) => { err.push(typeof s === 'string' ? s : new TextDecoder().decode(s)); return true; }) as typeof process.stderr.write; + console.log = (...a: unknown[]) => { out.push(a.map(String).join(' ') + '\n'); }; + console.info = console.log; + console.error = (...a: unknown[]) => { err.push(a.map(String).join(' ') + '\n'); }; + console.warn = console.error; + try { + const code = await runShipStep(key, argv, parseArgs, async a => { + const keep = console.log; + if (a.has('json')) console.log = (...x: unknown[]) => console.error(...x); + try { await body(a); } finally { console.log = keep; } + }); + return { code, out: out.join(''), err: err.join('') }; + } finally { process.stdout.write = o; process.stderr.write = e; console.log = cl; console.error = ce; console.warn = cw; console.info = ci; } +} + +// ------------------------------------------------------------------------------------------------ 1. release type + +describe('release type (pure)', () => { + test('config: afterApproval | manual | scheduled (+ date); scheduled needs the date, the date needs scheduled', () => { + expect(validateReleaseType({ releaseType: 'afterApproval' })).toEqual([]); + expect(validateReleaseType({ releaseType: 'scheduled', earliestReleaseDate: '2027-03-01T09:00:00Z' })).toEqual([]); + expect(validateReleaseType({ releaseType: 'soon' }).join()).toContain('afterApproval | manual | scheduled'); + expect(validateReleaseType({ releaseType: 'scheduled' }).join()).toContain('earliestReleaseDate is required'); + expect(validateReleaseType({ releaseType: 'manual', earliestReleaseDate: '2027-03-01T09:00:00Z' }).join()).toContain('only with apple.releaseType "scheduled"'); + expect(validateReleaseType({ releaseType: 'scheduled', earliestReleaseDate: 'tomorrow' }).join()).toContain('ISO 8601'); + expect(validateConfig({ ...BASE, apple: { ...BASE.apple!, releaseType: 'later' as never } }).join()).toContain('apple.releaseType'); + expect(wantedRelease({})).toBeNull(); + expect(wantedRelease({ releaseType: 'afterApproval' })).toEqual({ releaseType: 'AFTER_APPROVAL' }); + }); + test('changes are old -> new; the same instant is not a change', () => { + expect(releaseTypeChanges({ releaseType: 'MANUAL' }, { releaseType: 'AFTER_APPROVAL' })).toEqual([{ field: 'releaseType', from: 'MANUAL', to: 'AFTER_APPROVAL' }]); + expect(releaseTypeChanges({ releaseType: 'AFTER_APPROVAL' }, { releaseType: 'AFTER_APPROVAL' })).toEqual([]); + expect(releaseTypeChanges({ releaseType: 'SCHEDULED', earliestReleaseDate: '2027-03-01T09:00:00+00:00' }, { releaseType: 'SCHEDULED', earliestReleaseDate: '2027-03-01T09:00:00Z' })).toEqual([]); + expect(releaseTypeChanges({ releaseType: 'MANUAL' }, { releaseType: 'SCHEDULED', earliestReleaseDate: '2027-03-01T09:00:00Z' }).map(c => c.field)).toEqual(['releaseType', 'earliestReleaseDate']); + }); + test('apple listing: a new version takes the configured type (MANUAL when unset); an existing one keeps its own unless asked', () => { + const rows = [{ id: 'v1', attributes: { versionString: '1.0.0', appVersionState: 'READY_FOR_SALE', platform: 'IOS' } }]; + expect(planVersion(rows, '1.1.0', { copyright: 'c' }).attributes.releaseType).toBe('MANUAL'); + expect(planVersion(rows, '1.1.0', { copyright: 'c', releaseType: 'AFTER_APPROVAL' }).attributes.releaseType).toBe('AFTER_APPROVAL'); + const sched = planVersion(rows, '1.1.0', { copyright: 'c', releaseType: 'SCHEDULED', earliestReleaseDate: '2027-03-01T09:00:00Z' }); + expect(sched.attributes).toMatchObject({ releaseType: 'SCHEDULED', earliestReleaseDate: '2027-03-01T09:00:00Z' }); + const editable = [...rows, { id: 'v2', attributes: { versionString: '1.1.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS', copyright: 'c', releaseType: 'AFTER_APPROVAL' } }]; + expect(planVersion(editable, '1.1.0', { copyright: 'c' }).action).toBe('keep'); + expect(planVersion(editable, '1.1.0', { copyright: 'c', releaseType: 'MANUAL' }).changes).toEqual([{ field: 'releaseType', from: 'AFTER_APPROVAL', to: 'MANUAL' }]); + }); +}); + +describe('release type (fake App Store Connect)', () => { + setDefaultTimeout(30_000); + let s: Stores; + beforeAll(() => { ascEnv(); }); + afterAll(restore); + beforeEach(() => { s = installStores(); }); + afterEach(() => { uninstallStores(); }); + const version = (id: string, v: string, state: string, releaseType = 'MANUAL'): void => { s.asc.put({ type: 'appStoreVersions', id, attributes: { versionString: v, appVersionState: state, platform: 'IOS', releaseType }, rels: { app: APP } }); }; + const build = (): void => { s.asc.put({ type: 'builds', id: 'b-8', attributes: { version: '8', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP } }); }; + const typeOf = (id: string): unknown => s.asc.all('appStoreVersions').find(v => v.id === id)!.attributes.releaseType; + const cfg = (releaseType: 'afterApproval' | 'manual') => ({ ...BASE, apple: { ...BASE.apple!, releaseType } }); + + test('apple submit with apple.releaseType: the dry run shows old -> new, the real run sets it BEFORE the submit, the re-run is exit 7', async () => { + version('ver-0', '1.0.0', 'READY_FOR_SALE'); + version('ver-1', '1.1.0', 'PREPARE_FOR_SUBMISSION'); + build(); + const p = project(cfg('afterApproval')); + const dry = await ship('apple submit', ['--json', '--build', '8'], a => appleSubmit({ loaded: p.loaded, args: a })); + expect(dry.code).toBe(0); + expect(JSON.parse(dry.out).releaseType).toEqual({ changes: [{ field: 'releaseType', from: 'MANUAL', to: 'AFTER_APPROVAL' }] }); + expect(typeOf('ver-1')).toBe('MANUAL'); + expect(writes(s.calls)).toEqual([]); + const r = await ship('apple submit', ['--json', '--yes', '--build', '8'], a => appleSubmit({ loaded: p.loaded, args: a })); + expect(r.err).not.toContain('error:'); + expect(r.code).toBe(0); + expect(r.err).toContain('release type of 1.1.0: releaseType MANUAL -> AFTER_APPROVAL'); + expect(typeOf('ver-1')).toBe('AFTER_APPROVAL'); + const w = writes(s.calls).map(c => `${c.method} ${c.path}`); + expect(w.indexOf('PATCH /v1/appStoreVersions/ver-1')).toBeGreaterThanOrEqual(0); + expect(w.indexOf('PATCH /v1/appStoreVersions/ver-1')).toBeLessThan(w.findIndex(x => /^PATCH \/v1\/reviewSubmissions\//.test(x))); + const again = await ship('apple submit', ['--json', '--yes', '--build', '8'], a => appleSubmit({ loaded: p.loaded, args: a })); + expect(again.code).toBe(7); + }); + + test('apple submit without apple.releaseType leaves the version\'s release type alone', async () => { + version('ver-0', '1.0.0', 'READY_FOR_SALE'); + version('ver-1', '1.1.0', 'PREPARE_FOR_SUBMISSION'); + build(); + const p = project(BASE); + const r = await ship('apple submit', ['--json', '--yes', '--build', '8'], a => appleSubmit({ loaded: p.loaded, args: a })); + expect(r.code).toBe(0); + expect(typeOf('ver-1')).toBe('MANUAL'); + expect(writes(s.calls).some(c => c.path === '/v1/appStoreVersions/ver-1')).toBe(false); + }); + + test('apple release --after-approval --yes on an update: set, then exit 7 when it is that type; --manual puts it back', async () => { + version('ver-0', '1.0.0', 'READY_FOR_SALE'); + version('ver-1', '1.1.0', 'WAITING_FOR_REVIEW'); + const p = project(BASE); + const run = (argv: string[]) => ship('apple release', ['--json', '--yes', ...argv], a => appleRelease({ loaded: p.loaded, args: a })); + const r = await run(['--after-approval', '--version', '1.1.0']); + expect(r.err).not.toContain('error:'); + expect(r.code).toBe(0); + expect(JSON.parse(r.out)).toMatchObject({ ok: true, step: 'release', version: '1.1.0', releaseType: 'AFTER_APPROVAL', from: 'MANUAL', changed: true, releaseKind: 'update' }); + expect(writes(s.calls).map(c => `${c.method} ${c.path}`)).toEqual(['PATCH /v1/appStoreVersions/ver-1']); + expect(typeOf('ver-1')).toBe('AFTER_APPROVAL'); + const n = s.calls.length; + const again = await run(['--after-approval']); + expect(again.code).toBe(7); + expect(JSON.parse(again.out)).toMatchObject({ ok: true, alreadyThere: true }); + expect(writes(s.calls.slice(n))).toEqual([]); + const back = await run(['--manual', '--version', '1.1.0']); + expect(back.code).toBe(0); + expect(typeOf('ver-1')).toBe('MANUAL'); + }); + + test('apple release --after-approval --dry changes nothing; --scheduled sets the date; two type flags are a usage error', async () => { + version('ver-0', '1.0.0', 'READY_FOR_SALE'); + version('ver-1', '1.1.0', 'PREPARE_FOR_SUBMISSION'); + const p = project(BASE); + const dry = await ship('apple release', ['--json', '--after-approval', '--dry'], a => appleRelease({ loaded: p.loaded, args: a })); + expect(dry.code).toBe(0); + expect(JSON.parse(dry.out)).toMatchObject({ dryRun: true, changes: [{ field: 'releaseType', from: 'MANUAL', to: 'AFTER_APPROVAL' }] }); + expect(writes(s.calls)).toEqual([]); + const sch = await ship('apple release', ['--json', '--yes', '--scheduled', '2027-03-01T09:00:00Z'], a => appleRelease({ loaded: p.loaded, args: a })); + expect(sch.code).toBe(0); + expect(s.asc.all('appStoreVersions').find(v => v.id === 'ver-1')!.attributes).toMatchObject({ releaseType: 'SCHEDULED', earliestReleaseDate: '2027-03-01T09:00:00Z' }); + const both = await ship('apple release', ['--json', '--yes', '--manual', '--after-approval'], a => appleRelease({ loaded: p.loaded, args: a })); + expect(both.code).not.toBe(0); + expect(both.err).toContain('alternatives'); + }); + + test('an approved version (Pending Developer Release) is refused with the way out; a released one too', async () => { + version('ver-0', '1.0.0', 'READY_FOR_SALE'); + version('ver-1', '1.1.0', 'PENDING_DEVELOPER_RELEASE'); + const p = project(BASE); + const r = await ship('apple release', ['--json', '--yes', '--after-approval', '--version', '1.1.0'], a => appleRelease({ loaded: p.loaded, args: a })); + expect(r.code).not.toBe(0); + expect(r.err).toContain('apple release --manual-release --version 1.1.0'); + expect(writes(s.calls)).toEqual([]); + // already MANUAL and asked for MANUAL: nothing to do even in that state + const same = await ship('apple release', ['--json', '--yes', '--manual', '--version', '1.1.0'], a => appleRelease({ loaded: p.loaded, args: a })); + expect(same.code).toBe(7); + }); + + test('--after-approval on the first version of an app is the owner\'s step (exit 3 OWNER_STEP) unless --first-release', async () => { + version('ver-1', '1.0.0', 'PREPARE_FOR_SUBMISSION'); + const p = project(BASE); + const r = await ship('apple release', ['--json', '--yes', '--after-approval'], a => appleRelease({ loaded: p.loaded, args: a })); + expect(r.code).toBe(3); + expect(JSON.parse(r.out)).toMatchObject({ ok: false, error: { code: 'OWNER_STEP' } }); + expect(writes(s.calls)).toEqual([]); + const manual = await ship('apple release', ['--json', '--yes', '--manual'], a => appleRelease({ loaded: p.loaded, args: a })); + expect(manual.code).toBe(7); + const ok = await ship('apple release', ['--json', '--yes', '--after-approval', '--first-release'], a => appleRelease({ loaded: p.loaded, args: a })); + expect(ok.code).toBe(0); + expect(typeOf('ver-1')).toBe('AFTER_APPROVAL'); + }); +}); + +// ------------------------------------------------------------------------------------------------ 2. device families + +const PBX = (family: string, bundle = 'com.example.notes'): string => `// !$*UTF8*$! +{ + objects = { +/* Begin XCBuildConfiguration section */ + AA01 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = ${bundle}; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + AA02 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = ZZ01; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = ${bundle}; + TARGETED_DEVICE_FAMILY = ${family}; + }; + name = Release; + }; + AA03 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = ${bundle}.Share; + TARGETED_DEVICE_FAMILY = "1,2"; + WRAPPER_EXTENSION = appex; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + }; +} +`; + +describe('device families (pure)', () => { + test('pbxproj: the Release configuration of the app\'s bundle id, not the extension\'s or Debug', () => { + expect(pbxFamilies(PBX('1'), 'com.example.notes')).toMatchObject({ list: [1], families: { iphone: true, ipad: false }, configurations: ['1'] }); + expect(pbxFamilies(PBX('"1,2"'), 'com.example.notes')).toMatchObject({ list: [1, 2], families: { iphone: true, ipad: true } }); + expect(pbxFamilies('no project here', 'com.example.notes')).toBeNull(); + }); + test('live signals: any iPad signal means iPad; no signal at all is unknown, never iPhone only', () => { + expect(liveFamilies('1.0.0', 'READY_FOR_SALE', [{ name: 'screenshot sets', values: ['APP_IPHONE_67', 'APP_IPAD_PRO_3GEN_129'] }])?.families).toEqual({ iphone: true, ipad: true }); + expect(liveFamilies('1.0.0', 'READY_FOR_SALE', [{ name: 'a', values: [] }, { name: 'b', values: ['APP_IPHONE_67'] }])?.families).toEqual({ iphone: true, ipad: false }); + expect(liveFamilies('1.0.0', 'READY_FOR_SALE', [{ name: 'a', values: [] }])).toBeNull(); + }); + test('verdicts: the exact fix; unknown is a warning; iPhone-only live version is fine', () => { + const live = liveFamilies('1.0.0', 'READY_FOR_SALE', [{ name: 'screenshot sets', values: ['APP_IPAD_PRO_3GEN_129'] }]); + const project = pbxFamilies(PBX('1'), 'com.example.notes', 'ios/App/App.xcodeproj/project.pbxproj'); + const v = familyVerdicts({ live, project, ipad: false }); + expect(v.map(x => x.level)).toEqual(['blocker', 'blocker']); + expect(v[0]!.msg).toContain('ITMS-90101'); + expect(v[0]!.fix).toContain('"1,2"'); + expect(v[1]!.fix).toContain('apple.ipad: true'); + expect(familyVerdicts({ live, project: pbxFamilies(PBX('"1,2"'), 'com.example.notes'), ipad: true }).map(x => x.level)).toEqual(['ok']); + expect(familyVerdicts({ live: null, project, ipad: false })[0]!.level).toBe('warn'); + expect(familyVerdicts({ live: liveFamilies('1.0.0', 'READY_FOR_SALE', [{ name: 'a', values: ['APP_IPHONE_67'] }]), project, ipad: false })[0]!.level).toBe('ok'); + }); + test('validate rules turn the verdicts into findings', () => { + const f = appleRules({ deviceFamilies: [{ level: 'blocker', msg: 'x ITMS-90101', fix: 'y' }], version: null, build: null, expectedLocales: [], locales: [], requiredDisplayTypes: [], reviewDetail: null, ageRating: null, priceSchedule: true, availability: true, iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com', status: 200 } }).filter(x => x.code === 'device-families'); + expect(f).toMatchObject([{ store: 'apple', level: 'blocker', code: 'device-families', fix: 'y' }]); + }); +}); + +describe('device families (fake App Store Connect + project)', () => { + setDefaultTimeout(30_000); + let s: Stores; + beforeAll(() => { ascEnv(); }); + afterAll(restore); + beforeEach(() => { s = installStores(); }); + afterEach(() => { uninstallStores(); }); + /** a live version 1.0.0 with a build whose bundle names the families; screenshotSets: display types of its first locale */ + function live(o: { protocols?: string[]; sets?: string[]; models?: string[] }): void { + s.asc.put({ type: 'appStoreVersions', id: 'ver-live', attributes: { versionString: '1.0.0', appVersionState: 'READY_FOR_SALE', platform: 'IOS' }, rels: { app: APP } }); + s.asc.put({ type: 'builds', id: 'b-live', attributes: { version: '4', processingState: 'VALID' }, rels: { app: APP, appStoreVersion: 'ver-live' } }); + s.asc.put({ type: 'buildBundles', id: 'bb-live', attributes: { bundleId: 'com.example.notes', bundleType: 'APP', deviceProtocols: o.protocols ?? [] }, rels: { build: 'b-live' } }); + for (const [i, m] of (o.models ?? []).entries()) s.asc.put({ type: 'buildBundleFileSizes', id: `fs-${i}`, attributes: { deviceModel: m }, rels: { buildBundle: 'bb-live' } }); + if (o.sets) { + s.asc.put({ type: 'appStoreVersionLocalizations', id: 'loc-live', attributes: { locale: 'en-US' }, rels: { appStoreVersion: 'ver-live' } }); + for (const [i, t] of o.sets.entries()) s.asc.put({ type: 'appScreenshotSets', id: `set-${i}`, attributes: { screenshotDisplayType: t }, rels: { appStoreVersionLocalization: 'loc-live' } }); + } + } + const IPHONE_ONLY = { 'ios/App/App.xcodeproj/project.pbxproj': PBX('1') }; + const cfg = { ...BASE, apple: { ...BASE.apple!, ipad: false, signing: { teamId: 'ABCDE12345' } } }; + + test('the live version\'s families are read from the build bundle, its device models and its screenshot sets', async () => { + const c = (await import('../cli/apple/asc')).ascClient; + live({ sets: ['APP_IPHONE_67', 'APP_IPAD_PRO_3GEN_129'] }); + expect((await liveDeviceFamilies(await c('.', 'com.example.notes', APP), APP))?.families).toEqual({ iphone: true, ipad: true }); + uninstallStores(); s = installStores(); + live({ protocols: ['iphone', 'ipad'] }); + expect((await liveDeviceFamilies(await c('.', 'com.example.notes', APP), APP))?.evidence[0]).toContain('deviceProtocols'); + uninstallStores(); s = installStores(); + live({ models: ['iPad_Pro_12_9_6th', 'iPhone15,2'] }); + expect((await liveDeviceFamilies(await c('.', 'com.example.notes', APP), APP))?.families.ipad).toBe(true); + uninstallStores(); s = installStores(); + expect(await liveDeviceFamilies(await c('.', 'com.example.notes', APP), APP)).toBeNull(); + }); + + test('apple build stops BEFORE the archive when the project drops iPad that the live version has; --allow-device-gap goes on', async () => { + live({ sets: ['APP_IPHONE_67', 'APP_IPAD_PRO_3GEN_129'] }); + const argv = ['--build-number', '5', '--no-prebuild']; + const p = project(cfg, IPHONE_ONLY); + const r = await capture(() => appleBuild(p.ctx(argv))).catch(e => ({ out: '', err: String((e as Error).message ?? e), exit: 1 })); + expect(r.exit).toBe(1); + expect(r.err).toContain('stopped before the archive'); + expect(r.err).toContain('ITMS-90101'); + expect(r.err).toContain('TARGETED_DEVICE_FAMILY to "1,2"'); + expect(r.err).toContain('apple.ipad: true'); + expect(r.err).toContain('--allow-device-gap'); + // dry: warns, never throws + const dry = await capture(() => appleBuild(p.ctx([...argv, '--dry']))); + expect(dry.err).toContain('ITMS-90101'); + // the escape hatch passes the check (the run then stops later: no Xcode on this machine) + const gap = await capture(() => appleBuild(p.ctx([...argv, '--allow-device-gap']))).catch(e => ({ out: '', err: String((e as Error).message ?? e), exit: 1 })); + expect(gap.err).not.toContain('stopped before the archive'); + }); + + test('apple build passes the check with iPad in the project and apple.ipad true; an iPhone-only live version never blocks', async () => { + live({ sets: ['APP_IPHONE_67', 'APP_IPAD_PRO_3GEN_129'] }); + const ok = project({ ...cfg, apple: { ...cfg.apple, ipad: true } }, { 'ios/App/App.xcodeproj/project.pbxproj': PBX('"1,2"') }); + const r = await capture(() => appleBuild(ok.ctx(['--build-number', '5', '--no-prebuild', '--dry']))); + expect(r.err).not.toContain('ITMS-90101'); + uninstallStores(); s = installStores(); + live({ sets: ['APP_IPHONE_67'] }); + const iph = project(cfg, IPHONE_ONLY); + const r2 = await capture(() => appleBuild(iph.ctx(['--build-number', '5', '--no-prebuild', '--dry']))); + expect(r2.err).not.toContain('ITMS-90101'); + }); + + test('projectFamilies reads the project found under the root (and apple.build.project)', () => { + const p = project(cfg, IPHONE_ONLY); + expect(projectFamilies(p.root, 'com.example.notes')).toMatchObject({ file: 'ios/App/App.xcodeproj/project.pbxproj', list: [1] }); + expect(projectFamilies(p.root, 'com.example.notes', 'ios/App/App.xcodeproj')?.list).toEqual([1]); + expect(projectFamilies(project(cfg).root, 'com.example.notes')).toBeNull(); + }); +}); + +// ------------------------------------------------------------------------------------------------ 3. TestFlight invite + +describe('TestFlight invite needs an installable build', () => { + setDefaultTimeout(30_000); + let s: Stores; + beforeAll(() => { ascEnv(); }); + afterAll(restore); + beforeEach(() => { s = installStores(); }); + afterEach(() => { uninstallStores(); }); + const CSV = { '.storebound/testers.csv': 'email,first_name,groups\ntester1@example.com,Ex,Friends\n' }; + const cfg = { ...BASE, apple: { ...BASE.apple!, testflight: { groups: [{ name: 'Friends' }] } } }; + const group = (): void => { s.asc.put({ type: 'betaGroups', id: 'grp-friends', attributes: { name: 'Friends', isInternalGroup: false }, rels: { app: APP } }); }; + const buildIn = (external: string): void => { + s.asc.put({ type: 'builds', id: 'b-1', attributes: { version: '7', processingState: 'VALID', expired: false }, rels: { app: APP, betaGroups: ['grp-friends'] } }); + s.asc.put({ type: 'buildBetaDetails', id: 'bd-1', attributes: { externalBuildState: external, internalBuildState: 'IN_BETA_TESTING' }, rels: { build: 'b-1' } }); + }; + const invites = (): number => s.asc.all('betaTesterInvitations').length; + + test('pure: external needs BETA_APPROVED / IN_BETA_TESTING, internal READY_FOR_BETA_TESTING / IN_BETA_TESTING', () => { + const b = { attributes: { processingState: 'VALID' } }; + expect(installableBuild(b, { externalBuildState: 'READY_FOR_BETA_SUBMISSION' }, false)).toBe(false); + expect(installableBuild(b, { externalBuildState: 'WAITING_FOR_BETA_REVIEW' }, false)).toBe(false); + expect(installableBuild(b, { externalBuildState: 'BETA_APPROVED' }, false)).toBe(true); + expect(installableBuild(b, { externalBuildState: 'IN_BETA_TESTING' }, false)).toBe(true); + expect(installableBuild(b, { internalBuildState: 'READY_FOR_BETA_TESTING' }, true)).toBe(true); + expect(installableBuild({ attributes: { processingState: 'VALID', expired: true } }, { externalBuildState: 'BETA_APPROVED' }, false)).toBe(false); + expect(noInstallableBuildHint(['Friends'])).toBe('tester added; the invite goes out when a build in Friends passes Beta App Review (re-run with --invite then)'); + expect(noInstallableBuilds409(new Error('POST /v1/betaTesterInvitations -> 409 STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS'))).toBe(true); + expect(noInstallableBuilds409(new Error('-> 409 ALREADY_EXISTS'))).toBe(false); + }); + + test('a new external group without an approved build: the tester is added, no invitation is sent, exit 0 with the hint', async () => { + group(); + buildIn('WAITING_FOR_BETA_REVIEW'); + const p = project(cfg, CSV); + const r = await capture(() => appleTestflight(p.ctx(['testers', '--group', 'Friends', '--invite', '--yes']))); + expect(r.exit).toBeNull(); + expect(r.err).toContain('tester added; the invite goes out when a build in Friends passes Beta App Review (re-run with --invite then)'); + expect(s.asc.all('betaTesters')).toHaveLength(1); + expect(invites()).toBe(0); + expect(writes(s.calls).some(c => c.path === '/v1/betaTesterInvitations')).toBe(false); + }); + + test('once a build passed Beta App Review, --invite sends the invitation', async () => { + group(); + const p = project(cfg, CSV); + await capture(() => appleTestflight(p.ctx(['testers', '--group', 'Friends', '--invite', '--yes']))); + expect(invites()).toBe(0); + buildIn('BETA_APPROVED'); + const r = await capture(() => appleTestflight(p.ctx(['testers', '--group', 'Friends', '--invite', '--yes']))); + expect(r.exit).toBeNull(); + expect(invites()).toBe(1); + }); + + test('if the 409 still comes back (the build looked installable), it is the same case: warning, no throw, exit 0', async () => { + group(); + buildIn('BETA_APPROVED'); + const p = project(cfg, CSV); + // Apple's answer wins over our read: the double refuses the invitation as the live API did + s.faults.push({ method: 'POST', match: /\/v1\/betaTesterInvitations/, when: 'before', status: 409, code: 'STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS', detail: 'The tester cannot be invited because the groups they belong to have no installable builds.' }); + const r = await capture(() => appleTestflight(p.ctx(['testers', '--group', 'Friends', '--invite', '--yes']))); + expect(r.exit).toBeNull(); + expect(r.err).toContain('passes Beta App Review'); + expect(s.asc.all('betaTesters')).toHaveLength(1); + }); + + test('distribute --submit-review prints the hint for testers that are added but not invited', async () => { + group(); + buildIn('READY_FOR_BETA_SUBMISSION'); + const p = project(cfg, CSV); + await capture(() => appleTestflight(p.ctx(['testers', '--group', 'Friends', '--yes']))); + s.asc.all('buildBetaDetails')[0]!.attributes.processingState = 'x'; + s.asc.put({ type: 'preReleaseVersions', id: 'pre-1', attributes: { version: '1.0.0', platform: 'IOS' }, rels: { app: APP } }); + s.asc.all('builds')[0]!.rels.preReleaseVersion = 'pre-1'; + s.asc.all('builds')[0]!.rels.buildBetaDetail = 'bd-1'; + const r = await capture(() => appleTestflight(p.ctx(['distribute', '--build', '7', '--group', 'Friends', '--submit-review', '--yes']))); + expect(r.out + r.err).toContain('tester added; the invite goes out when a build in Friends passes Beta App Review'); + }); +}); diff --git a/tests/helpers/fake-stores.ts b/tests/helpers/fake-stores.ts index d89233f..272ec93 100644 --- a/tests/helpers/fake-stores.ts +++ b/tests/helpers/fake-stores.ts @@ -76,6 +76,10 @@ const LISTS: Record = { 'apps/promotedPurchases': { type: 'promotedPurchases' }, 'subscriptions/winBackOffers': { type: 'winBackOffers' }, 'betaGroups/betaTesters': { type: 'betaTesters' }, + 'betaGroups/builds': { type: 'builds' }, + 'builds/buildBetaDetail': { type: 'buildBetaDetails', one: true }, + 'builds/betaAppReviewSubmission': { type: 'betaAppReviewSubmissions', one: true }, + 'buildBundles/buildBundleFileSizes': { type: 'buildBundleFileSizes' }, 'apps/analyticsReportRequests': { type: 'analyticsReportRequests' }, 'apps/assetLibrary': { type: 'appAssetLibraries', one: true }, 'appAssetLibraries/images': { type: 'appAssetLibraryImages' }, @@ -162,6 +166,21 @@ export class FakeAsc { return { type: r.type, id: r.id, attributes: r.attributes, relationships }; } + /** Whether a group of the tester has a build its testers can install (external: BETA_APPROVED / IN_BETA_TESTING; internal: READY_FOR_BETA_TESTING / IN_BETA_TESTING). */ + hasInstallableBuild(testerId: string): boolean { + const gids = [this.res.get(testerId)?.rels.betaGroups ?? []].flat(); + return gids.some(gid => { + const g = this.res.get(gid); + if (!g) return false; + const internal = g.attributes.isInternalGroup === true; + const builds = internal && g.attributes.hasAccessToAllBuilds === true ? this.all('builds') : this.children(gid, 'builds'); + return builds.some(b => { + const st = String(this.children(b.id, 'buildBetaDetails')[0]?.attributes[internal ? 'internalBuildState' : 'externalBuildState'] ?? ''); + return internal ? ['READY_FOR_BETA_TESTING', 'IN_BETA_TESTING'].includes(st) : ['BETA_APPROVED', 'IN_BETA_TESTING'].includes(st); + }); + }); + } + children(parent: string, type: string): R[] { return this.all(type).filter(r => Object.values(r.rels).some(v => (Array.isArray(v) ? v.includes(parent) : v === parent))); } @@ -205,6 +224,11 @@ export class FakeAsc { if (v && v.attributes.state === 'DEVELOPER_REJECTED' && v.type !== 'appStoreVersions') v.attributes.state = 'READY_FOR_REVIEW'; main.attributes.state ??= 'READY_FOR_REVIEW'; } + // seen live: inviting a tester whose groups have no installable build (an external build is installable after Beta App + // Review) is 409 STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS + if (d.type === 'betaTesterInvitations' && !this.hasInstallableBuild(String(main.rels.betaTester))) + return json({ errors: [{ id: '00000000-0000-0000-0000-000000000000', status: '409', code: 'STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS', title: 'There are no installable builds', + detail: 'The tester cannot be invited because the groups they belong to have no installable builds.' }] }, 409); const u = UNIQUE[d.type]; if (u && this.all(d.type).some(r => u(r) === u(main as R))) return json({ errors: [{ status: '409', code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE', title: 'The provided entity includes an attribute with a value that has already been used', detail: 'already exists' }] }, 409); @@ -267,7 +291,8 @@ export class FakeAsc { } if (!id) return json({ data: this.visible(this.filtered(this.all(type!), u.searchParams)).map(r => this.view(r)), links: {} }); const self = this.res.get(id); - if (!rel) return self && self.type === type ? json({ data: this.view(self) }) : json({ errors: [{ status: '404' }] }, 404); + // include=: the children of that type (a build's buildBundles) + if (!rel) return self && self.type === type ? json({ data: this.view(self), included: (u.searchParams.get('include') ?? '').split(',').filter(Boolean).flatMap(t => this.children(self.id, t).map(r => this.view(r))) }) : json({ errors: [{ status: '404' }] }, 404); if (rel === 'relationships') return json({ data: this.children(id, rel2!).map(r => ({ type: r.type, id: r.id })) }); // an availability's territories are the ids its create named (a to-many relationship, not child resources) if (rel === 'availableTerritories' && self) { diff --git a/tests/signing-targets.test.ts b/tests/signing-targets.test.ts index b3c6bc3..8f681b3 100644 --- a/tests/signing-targets.test.ts +++ b/tests/signing-targets.test.ts @@ -361,6 +361,8 @@ describe('App Group association under automatic signing (fake App Store Connect) { match: new RegExp(`^/v1/bundleIds/${MAIN}/profiles`), answer: () => page(o.main ?? [managed('com.example.notes', GROUP)]) }, { match: /^\/v1\/profiles\/MANAGED_[^/]+\/relationships\/certificates/, answer: { errors: [{ status: '404' }] }, status: 404 }, { match: /^\/v1\/apps\/6400000101\/gameCenterDetail/, answer: { errors: [{ status: '404' }] }, status: 404 }, + // apple build compares the live version's device families first: an app without versions has none + { match: /^\/v1\/apps\/6400000101\/appStoreVersions/, answer: page([]) }, ] as never; } const ensure = async (argv: string[] = ['ensure', '--yes']) => {