// 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 // app with --yes is exit 3 OWNER_STEP unless the owner approved it (--first-release, or bypass mode; // cli/lib/release-kind.ts). Neither --manual-release nor resume / complete is offered to agents through `storebound mcp`. import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { done, log, printJson } from '../lib/log'; import { shipNoop, shipReport, shipRun } from '../lib/ship-step'; 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'; export async function run(ctx: Ctx): Promise { const { config, root } = ctx.loaded; const a = ctx.args; const phased = a.get('phased') as PhasedAction | undefined; const manual = a.has('manual-release'); 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 if (manual && shipRun()) { const every = await allVersions(c, appId); const waiting = every.some((x: J) => state(x) === 'PENDING_DEVELOPER_RELEASE' && (!want || x.attributes.versionString === want)); const out = every.find((x: J) => ['READY_FOR_SALE', 'READY_FOR_DISTRIBUTION', 'PROCESSING_FOR_DISTRIBUTION'].includes(state(x)) && (!want || x.attributes.versionString === want)); if (!waiting && out) { shipNoop({ version: String(out.attributes.versionString), state: state(out) }); if (a.has('json')) printJson({ version: String(out.attributes.versionString), state: state(out), released: true, alreadyThere: true }); done('apple', `${String(out.attributes.versionString)} is ${state(out)} already: nothing released`); return; } } // the platform: --platform, else the one with the version this needs (both: asks) const needs = (x: J): boolean => (!want || x.attributes.versionString === want) && (manual ? state(x) === 'PENDING_DEVELOPER_RELEASE' : phased === 'on' ? !['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE', 'REPLACED_WITH_NEW_VERSION', 'REMOVED_FROM_SALE'].includes(state(x)) : ['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE', 'PENDING_DEVELOPER_RELEASE', 'PENDING_APPLE_RELEASE'].includes(state(x))); const platform = parsePlatform(a.get('platform')) ?? pickPlatform(await allVersions(c, appId), want ? [needs, (x: J) => x.attributes.versionString === want] : needs, undefined, manual ? 'a version waiting for developer release' : `a version for phased release ${phased}`); const versions = await c.all(versionsPath(appId, platform, 20)); const v: J | undefined = want ? versions.find(x => x.attributes.versionString === want) : manual ? versions.find(x => state(x) === 'PENDING_DEVELOPER_RELEASE') : phased === 'on' ? versions.find(x => !['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE', 'REPLACED_WITH_NEW_VERSION', 'REMOVED_FROM_SALE'].includes(state(x))) : versions.find(x => ['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE'].includes(state(x))) ?? versions[0]; if (!v) throw new Error(`${platformLabel(platform)}: ${want ? `no version ${want}` : manual ? 'no version waiting for developer release (PENDING_DEVELOPER_RELEASE)' : 'no matching version'}`); const tag = `${v.attributes.versionString} ${platformLabel(platform)} (${state(v)})`; if (manual) { if (state(v) !== 'PENDING_DEVELOPER_RELEASE') throw new Error(`${tag}: only an approved version waiting for developer release can be released`); const pr = (await c.maybe(`/v1/appStoreVersions/${v.id}/appStoreVersionPhasedRelease`))?.data; const how = pr ? 'phased over 7 days (phased release is on)' : 'to everyone at once (no phased release)'; const kind = appleReleaseKind(versions.map((x: J) => ({ versionString: String(x.attributes.versionString), state: state(x) }))); shipReport({ version: String(v.attributes.versionString), state: state(v) }); if (dry) { log('apple', `would release ${tag} ${how} (dry): ${kind.kind === 'update' ? 'update of an app on sale, no owner step' : 'FIRST release of this app (the owner approves it once: --first-release)'} (${kind.why})`); if (a.has('json')) printJson({ version: String(v.attributes.versionString), state: state(v), releaseKind: kind.kind, dryRun: true }); return; } if (!enforceFirstRelease(ctx, kind, { store: 'apple', what: 'the first release of the app', consoleUrl: consoleUrl('asc.version') })) return; if (!(await confirm(ctx, `Release ${tag} on the App Store now, ${how}? This is public.`))) return; await c.asc('POST', '/v1/appStoreVersionReleaseRequests', { data: { type: 'appStoreVersionReleaseRequests', relationships: { appStoreVersion: { data: { type: 'appStoreVersions', id: v.id } } } } }); shipReport({ state: 'PENDING_APPLE_RELEASE' }); if (a.has('json')) printJson({ version: String(v.attributes.versionString), state: 'PENDING_APPLE_RELEASE', releaseKind: kind.kind, released: true }); done('apple', `release requested for ${tag}; the store updates within a few hours`); return; } const cur = (await c.maybe(`/v1/appStoreVersions/${v.id}/appStoreVersionPhasedRelease`))?.data as J | undefined; const req = phasedRequest(cur ? { id: cur.id, state: cur.attributes.phasedReleaseState } : null, phased!); if (req.method === 'NONE') { log('apple', `${tag}: ${req.note}`); return; } if (dry) { log('apple', `${tag}: would ${req.method === 'POST' ? 'turn phased release on' : `set phased release ${req.state}`} (dry)`); return; } if ((phased === 'resume' || phased === 'complete') && !(await confirm(ctx, `${phased === 'complete' ? 'Release' : 'Resume the phased release of'} ${tag} ${phased === 'complete' ? 'to every user now' : ''}?`))) return; if (req.method === 'POST') { // one phased release per version: a 409 or an unclear answer is checked by reading it back, never re-sent blindly await createOnce({ what: `phased release of ${tag}`, tag: 'apple', unique: true, find: async () => (await c.maybe(`/v1/appStoreVersions/${v.id}/appStoreVersionPhasedRelease`))?.data ?? null, create: async () => (await c.asc('POST', '/v1/appStoreVersionPhasedReleases', { data: { type: 'appStoreVersionPhasedReleases', relationships: { appStoreVersion: { data: { type: 'appStoreVersions', id: v.id } } } } })).data }); } else { await c.asc('PATCH', `/v1/appStoreVersionPhasedReleases/${cur!.id}`, { data: { type: 'appStoreVersionPhasedReleases', id: cur!.id, attributes: { phasedReleaseState: req.state } } }); } 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\b/.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)}`); }