Updates are agent steps: --yes ships an update of a live app (Play production, App Store submit / release) without an owner gate; the first release stays the owner's (exit 3 OWNER_STEP, --first-release); apple release is a ship step

This commit is contained in:
CRTkafa 2026-10-26 18:09:50 +07:00
parent c247b7e54a
commit ae9d26d3f6
28 changed files with 562 additions and 44 deletions

View file

@ -1,15 +1,21 @@
// 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]
// storebound apple release --manual-release [--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.
// --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`.
// --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 } from '../lib/log';
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 { ascClient, type J } from './asc';
@ -27,6 +33,19 @@ export async function run(ctx: Ctx): Promise<void> {
const appId = await c.app();
const want = a.get('version');
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))
@ -44,10 +63,19 @@ export async function run(ctx: Ctx): Promise<void> {
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; }
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;
}

View file

@ -1,5 +1,5 @@
// Resubmit an App Store version after a rejection (Invalid Binary, Guideline 2.1, ...) with a new build.
// storebound apple resubmit --build <n> [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--dry] [--yes] [--json]
// storebound apple resubmit --build <n> [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--first-release] [--dry] [--yes] [--json]
// The platform: --platform, else the one with a rejected version or an UNRESOLVED_ISSUES submission (both: asks).
// 1. The rejected version (--version, else the one in the submission back from App Review, else the one in a
// rejected state) gets build n attached, by explicit number (never "the latest"); it must be VALID.
@ -13,7 +13,10 @@
// 4. PATCH submitted: true. On "not ready yet" the items are read again: a REJECTED one left over stops the run with
// the item and how to resolve it (retrying never passes); only when none is left is the submit retried with backoff
// (right after a build is attached), with heartbeat lines and a 10-minute limit.
// Submitting is the owner's step: without --yes this is a dry run with the plan table. Not offered through MCP.
// Without --yes this is a dry run with the plan table. Submitting an UPDATE of an app that is already on sale (a version
// of the platform is or was READY_FOR_SALE) is an agent step: --yes sends it, no owner gate, also in a pipeline. The
// first-ever submission of an app is the owner's: with --yes it is exit 3 OWNER_STEP unless the owner approved it
// (--first-release, or bypass mode); cli/lib/release-kind.ts. Not offered through MCP.
// `storebound apple submit --build <n>` (cli/apple/submit.ts) runs the same steps for a first submission: the version
// 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.
@ -30,6 +33,7 @@ import { openSubmissions } from './submission';
import { ensureGcSwitch, gameCenterInUse, gcSwitchPlan, readGcSwitch } from './game-center';
import { shipNoop, shipPhase, shipReport, shipRun } from '../lib/ship-step';
import { consoleUrl } from '../lib/console-links';
import { appleReleaseKind, enforceFirstRelease } from '../lib/release-kind';
import { allVersions, parsePlatform, pickPlatform, platformLabel, platformOf, versionsPath } from './platform';
/** App Store version states a first submission may send: the editable version (Prepare for Submission, a rejected
@ -112,13 +116,14 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
const marks = state.choice?.action === 'reuse' ? toResolve(state.choice.sub.items, resolve).map(i => ({ item: i.id ?? null, kind: i.kind, what: labelOf(state, i.versionId, i.kind) })) : [];
const blockers = [...(build.action === 'refuse' ? [build.reason] : []), ...plan.blockers];
const dry = a.has('dry') || !a.has('yes');
const kind = appleReleaseKind(versions.map((x: J) => ({ versionString: String(x.attributes.versionString), state: stateOf(x) })));
// Game Center: a build with the entitlement needs the app's Game Center detail and the version's switch, else the
// submission answers 409 STATE_ERROR.BUILD_INDICATES_GAME_CENTER_ENABLED (seen live); the same upsert as apple game-center
const gcUse = gameCenterInUse(config, root);
const gc = gcUse.inUse ? await readGcSwitch(c, appId, v) : null;
const gcPlan = gc ? gcSwitchPlan(gc) : [];
if (a.has('json')) {
printJson({ dryRun: dry, 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, 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;
@ -126,6 +131,7 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
if (dry || blockers.length || state.choice?.action === 'busy') return;
} 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}`);
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);
@ -133,9 +139,10 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
if (blockers.length) { process.exitCode = 1; done(TAG, 'nothing sent: fix the blockers above'); return; }
if (state.choice?.action === 'busy') { shipNoop({ version: versionString, state: stateOf(v) }); done(TAG, `nothing to ${mode}: ${state.choice.why}`); return; }
if (dry) {
done(TAG, a.has('dry') ? 'dry run: nothing sent' : `dry run (no --yes): nothing sent. ${mode === 'submit' ? 'Submitting' : 'Resubmitting'} is the owner's step: after the owner's yes, run it again with --yes`);
done(TAG, a.has('dry') ? 'dry run: nothing sent' : `dry run (no --yes): nothing sent. ${kind.kind === 'update' ? 'This is an update of an app on sale: run it again with --yes, no owner step' : `${mode === 'submit' ? 'Submitting' : 'Resubmitting'} the first release is the owner's step: after the owner's yes, run it again with --yes --first-release`}`);
return;
}
if (!enforceFirstRelease(ctx, kind, { store: 'apple', what: 'the first submission to App Review', consoleUrl: consoleUrl('asc.version') })) return;
shipPhase('submit', `${mode === 'submit' ? 'submitting' : 'resubmitting'} ${versionString} with build ${number}`);
if (build.action === 'attach') {