apple: release type (apple.releaseType, apple release --after-approval|--manual), device-family preflight, TestFlight invite needs an installable build
This commit is contained in:
parent
4763862178
commit
85cf494c0a
24 changed files with 899 additions and 18 deletions
|
|
@ -1,10 +1,17 @@
|
|||
// Release management for an App Store version.
|
||||
// storebound apple release --phased on|pause|resume|complete [--version 1.2.0] [--dry] [--yes]
|
||||
// storebound apple release --manual-release [--version 1.2.0] [--first-release] [--yes] [--json]
|
||||
// storebound apple release --after-approval | --manual | --scheduled <ISO date> [--version 1.2.0] [--first-release] [--yes] [--json]
|
||||
// Both take --platform ios|macos|tvos|visionos; without it the platform that has the version this needs is used (both: asks).
|
||||
// Phased release: 7-day automatic rollout to users with automatic updates (1, 2, 5, 10, 20, 50, 100 %); anyone can
|
||||
// still update by hand from the App Store. Pause holds it (up to 30 days in total), it is NOT a rollback: users who
|
||||
// updated keep the build. "complete" releases to everyone at once.
|
||||
// --after-approval / --manual / --scheduled set how the version goes live (appStoreVersions.releaseType; the config's
|
||||
// apple.releaseType does the same in `apple listing` and `apple submit`). Allowed until App Review approves the version
|
||||
// (Prepare for Submission, rejected, Waiting for Review, In Review); an approved one (Pending Developer Release) is
|
||||
// released with --manual-release, a released one is final. Setting it when it is that type already is exit 7 under the
|
||||
// ship-step contract. An update of an app on sale is an agent step; --after-approval on the first version of an app
|
||||
// puts it on sale by itself, so that one is the owner's (exit 3 OWNER_STEP unless --first-release).
|
||||
// --manual-release releases an approved version (PENDING_DEVELOPER_RELEASE). Public and irreversible: asks first
|
||||
// (--yes in scripts). Releasing an UPDATE of an app already on sale is an agent step (no owner gate, also in a
|
||||
// pipeline; under the ship-step contract: exit 7 when the version is released already). The first-ever release of an
|
||||
|
|
@ -18,6 +25,7 @@ import { consoleUrl } from '../lib/console-links';
|
|||
import { appleReleaseKind, enforceFirstRelease } from '../lib/release-kind';
|
||||
import { type PhasedAction, phasedRequest } from '../lib/release';
|
||||
import { createOnce } from '../lib/upsert';
|
||||
import { ASC_RELEASE_TYPE, changeText, RELEASE_TYPE_EDITABLE, releaseTypeChanges, type WantedRelease } from '../lib/release-type';
|
||||
import { ascClient, type J } from './asc';
|
||||
import { allVersions, parsePlatform, pickPlatform, platformLabel, versionsPath } from './platform';
|
||||
|
||||
|
|
@ -26,12 +34,16 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const a = ctx.args;
|
||||
const phased = a.get('phased') as PhasedAction | undefined;
|
||||
const manual = a.has('manual-release');
|
||||
if (!phased && !manual) throw new Error('usage: apple release --phased on|pause|resume|complete | --manual-release [--version x.y.z] [--dry] [--yes]');
|
||||
const typeFlags = [a.has('after-approval') && 'afterApproval', a.has('manual') && 'manual', a.has('scheduled') && 'scheduled'].filter((x): x is 'afterApproval' | 'manual' | 'scheduled' => !!x);
|
||||
if (typeFlags.length > 1) throw new Error('--after-approval, --manual and --scheduled are alternatives: give one');
|
||||
if (!phased && !manual && !typeFlags.length) throw new Error('usage: apple release --phased on|pause|resume|complete | --manual-release | --after-approval | --manual | --scheduled <ISO date> [--version x.y.z] [--dry] [--yes]');
|
||||
if (typeFlags.length && (phased || manual)) throw new Error('the release type flags (--after-approval, --manual, --scheduled) are a command of their own: run --phased / --manual-release separately');
|
||||
if (phased && !['on', 'pause', 'resume', 'complete'].includes(phased)) throw new Error('--phased: on | pause | resume | complete');
|
||||
const dry = a.has('dry');
|
||||
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||
const appId = await c.app();
|
||||
const want = a.get('version');
|
||||
if (typeFlags.length) return setReleaseType(ctx, c, appId, typeFlags[0]!);
|
||||
const state = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState;
|
||||
shipReport({ version: want, url: consoleUrl('asc.version') });
|
||||
// the ship-step contract: the version is released already (and none waits for release): nothing to do, exit 7
|
||||
|
|
@ -96,3 +108,52 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
}
|
||||
done('apple', `${tag}: ${req.note}`);
|
||||
}
|
||||
|
||||
/** apple release --after-approval | --manual | --scheduled: PATCH appStoreVersions releaseType on a version App Review has not approved yet. */
|
||||
async function setReleaseType(ctx: Ctx, c: Awaited<ReturnType<typeof ascClient>>, appId: string, name: 'afterApproval' | 'manual' | 'scheduled'): Promise<void> {
|
||||
const a = ctx.args;
|
||||
const TAG = 'apple';
|
||||
const want = a.get('version');
|
||||
const state = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState;
|
||||
const date = name === 'scheduled' ? a.required('scheduled', 'ISO date and time', 'e.g. --scheduled 2027-03-01T09:00:00Z') : undefined;
|
||||
if (date && Number.isNaN(Date.parse(date))) throw new Error(`--scheduled ${date}: an ISO 8601 date and time, e.g. 2027-03-01T09:00:00Z`);
|
||||
const target: WantedRelease = { releaseType: ASC_RELEASE_TYPE[name], ...(date ? { earliestReleaseDate: date } : {}) };
|
||||
shipReport({ version: want, url: consoleUrl('asc.version') });
|
||||
const fits = (x: J): boolean => (!want || x.attributes.versionString === want) && (RELEASE_TYPE_EDITABLE.has(state(x)) || state(x) === 'PENDING_DEVELOPER_RELEASE');
|
||||
const platform = parsePlatform(a.get('platform')) ?? pickPlatform(await allVersions(c, appId), want ? [fits, (x: J) => x.attributes.versionString === want] : fits, undefined, 'a version that is not approved yet');
|
||||
const versions = await c.all(versionsPath(appId, platform, 20));
|
||||
const v: J | undefined = want ? versions.find(x => x.attributes.versionString === want) : versions.find(x => RELEASE_TYPE_EDITABLE.has(state(x))) ?? versions.find(x => state(x) === 'PENDING_DEVELOPER_RELEASE');
|
||||
if (!v) throw new Error(`${platformLabel(platform)}: ${want ? `no version ${want}` : 'no version waiting for App Review (Prepare for Submission, rejected, Waiting for Review, In Review)'}`);
|
||||
const versionString = String(v.attributes.versionString);
|
||||
const tag = `${versionString} ${platformLabel(platform)} (${state(v)})`;
|
||||
const changes = releaseTypeChanges(v.attributes, target);
|
||||
const kind = appleReleaseKind(versions.map((x: J) => ({ versionString: String(x.attributes.versionString), state: state(x) })));
|
||||
shipReport({ version: versionString, state: state(v) });
|
||||
const result = (extra: Record<string, unknown>): void => { if (a.has('json')) printJson({ version: versionString, state: state(v), platform, releaseType: target.releaseType, from: v.attributes.releaseType ?? null, releaseKind: kind.kind, ...extra }); };
|
||||
if (!changes.length) {
|
||||
shipNoop({ version: versionString, state: state(v) });
|
||||
result({ changed: false, alreadyThere: true });
|
||||
done(TAG, `${tag}: release type is ${target.releaseType} already: nothing changed`);
|
||||
return;
|
||||
}
|
||||
if (!RELEASE_TYPE_EDITABLE.has(state(v))) {
|
||||
throw new Error(`${tag}: the release type can no longer be changed${state(v) === 'PENDING_DEVELOPER_RELEASE' ? ` (App Review approved it and it waits for you: release it with storebound apple release --manual-release --version ${versionString})` : ' (it is approved or released; App Store Connect only takes the change before approval)'}`);
|
||||
}
|
||||
if (a.has('dry')) {
|
||||
log(TAG, `would set the release type of ${tag}: ${changeText(changes)} (dry)`);
|
||||
result({ changed: false, dryRun: true, changes });
|
||||
return;
|
||||
}
|
||||
// after approval = on sale at once: for the first version of an app that is the owner's step
|
||||
if (target.releaseType === 'AFTER_APPROVAL' && !enforceFirstRelease(ctx, kind, { store: 'apple', what: 'the first release of the app (it goes on sale by itself once approved)', consoleUrl: consoleUrl('asc.version') })) return;
|
||||
if (!(await confirm(ctx, `Set the release type of ${tag}: ${changeText(changes)}?`))) return;
|
||||
try {
|
||||
await c.asc('PATCH', `/v1/appStoreVersions/${v.id}`, { data: { type: 'appStoreVersions', id: v.id, attributes: Object.fromEntries(changes.map(ch => [ch.field, ch.to])) } });
|
||||
} catch (e) {
|
||||
if (/-> 409/.test((e as Error).message)) throw new Error(`${tag}: App Store Connect refuses the release type change now (409): ${(e as Error).message}
|
||||
It is editable until App Review approves the version; after that use apple release --manual-release.`);
|
||||
throw e;
|
||||
}
|
||||
result({ changed: true, changes });
|
||||
done(TAG, `${tag}: release type ${changeText(changes)}`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue