63 lines
4.8 KiB
TypeScript
63 lines
4.8 KiB
TypeScript
// 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] [--yes]
|
|
// 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.
|
|
// --manual-release releases an approved version (PENDING_DEVELOPER_RELEASE). Public and irreversible: asks first.
|
|
// 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 } from '../lib/log';
|
|
import { type PhasedAction, phasedRequest } from '../lib/release';
|
|
import { createOnce } from '../lib/upsert';
|
|
import { ascClient, type J } from './asc';
|
|
|
|
export async function run(ctx: Ctx): Promise<void> {
|
|
const { config, root } = ctx.loaded;
|
|
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]');
|
|
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 versions = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=20`);
|
|
const want = a.get('version');
|
|
const state = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState;
|
|
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(want ? `no version ${want}` : manual ? 'no version waiting for developer release (PENDING_DEVELOPER_RELEASE)' : 'no matching version');
|
|
const tag = `${v.attributes.versionString} (${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)';
|
|
if (dry) { log('apple', `would release ${tag} ${how} (dry)`); 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 } } } } });
|
|
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}`);
|
|
}
|