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:
parent
c247b7e54a
commit
ae9d26d3f6
28 changed files with 562 additions and 44 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -59,9 +59,9 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
|||
'play listing': [() => import('./play/listing'), 'app details + title / short / full description per language; pull [--write]: live listings back into the listing file (diff first)'],
|
||||
'play images': [() => import('./play/images'), 'icon, feature graphic, phone screenshots per language, optional tablet / TV / Wear sets when rendered (play.tablets); --variant <id>, --prune'],
|
||||
'play data-safety': [() => import('./play/data-safety'), '[--force] Data safety form (CSV import through the API; skipped when unchanged since the last upload)'],
|
||||
'play release': [() => import('./play/release'), 'upload an AAB and put it on tracks with release notes'],
|
||||
'play release': [() => import('./play/release'), 'upload an AAB and put it on tracks with release notes; --production completed|inProgress --yes ships an UPDATE of a published app without an owner step (a first-ever production release is the owner\'s: exit 3 OWNER_STEP, --first-release after the owner\'s yes)'],
|
||||
'play build': [() => import('./play/build'), 'gradle bundleRelease (Flutter: flutter build appbundle) with the next free versionCode and the upload key from env / .storebound; --dry'],
|
||||
'play upload': [() => import('./play/upload'), 'upload an AAB to a testing track (--track internal|alpha|beta), versionCode checked first; --dry read-only, asks first'],
|
||||
'play upload': [() => import('./play/upload'), 'upload an AAB to a track (--track internal|alpha|beta|production), versionCode checked first; production with --yes is an update step for a published app (a first-ever release is the owner\'s: exit 3 OWNER_STEP); --dry read-only, asks first'],
|
||||
'play status': [() => import('./play/status'), 'tracks, listings, products (read-only)'],
|
||||
'play rollout': [() => import('./play/rollout'), 'staged rollout: --fraction x | --halt | --resume | --complete (asks first)'],
|
||||
'play share': [() => import('./play/share'), 'internal app sharing: upload an AAB / APK, print the share link (asks first; --dry)'],
|
||||
|
|
@ -74,7 +74,7 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
|||
'apple subscriptions': [() => import('./apple/subscriptions'), 'subscription groups, subscriptions, localizations, prices (equalized + overrides + --csv), intro offers, availability, review screenshot (asks first)'],
|
||||
'apple iap-review': [() => import('./apple/iap-review'), 'IAP review screenshots + product states'],
|
||||
'apple iap-submit': [() => import('./apple/iap-submit'), 'IAP + subscription versions into a review submission (first of a type: --with-version), then submit (asks first; --no-submit keeps a draft); reuses the open submission, never a second one'],
|
||||
'apple submit': [() => import('./apple/submit'), 'a version to App Review: --build <n> attached (by number, VALID), the open submission reused, the ready IAPs / subscriptions with it, submitted with retry (dry run unless --yes); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
|
||||
'apple submit': [() => import('./apple/submit'), 'a version to App Review: --build <n> attached (by number, VALID), the open submission reused, the ready IAPs / subscriptions with it, submitted with retry (dry run unless --yes; an UPDATE of an app on sale needs no owner step, the first-ever submission 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 resubmit': [() => import('./apple/resubmit'), 'after a rejection: --build <n> attached to the rejected version, the UNRESOLVED_ISSUES submission reused (missing items added), resubmitted with retry (dry run unless --yes); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
|
||||
'apple offer-codes': [() => import('./apple/offer-codes'), 'offer codes for one-time purchases (campaigns; custom and one-time-use codes with --codes, asks first)'],
|
||||
'apple win-back': [() => import('./apple/win-back'), 'win-back offers for lapsed subscribers (asks first: they go live on their start date)'],
|
||||
|
|
@ -89,7 +89,7 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
|||
'apple status': [() => import('./apple/status'), 'versions, builds, IAPs, review submissions (--wait-review, --cancel-submission <id>); --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); --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, --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 <src-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'],
|
||||
|
|
|
|||
|
|
@ -228,14 +228,20 @@
|
|||
"enable"
|
||||
],
|
||||
"apple release": [
|
||||
"env-file",
|
||||
"first-release",
|
||||
"manual-release",
|
||||
"phased",
|
||||
"platform",
|
||||
"version"
|
||||
"progress",
|
||||
"result-file",
|
||||
"version",
|
||||
"version-from"
|
||||
],
|
||||
"apple resubmit": [
|
||||
"build",
|
||||
"env-file",
|
||||
"first-release",
|
||||
"keychain",
|
||||
"locale-map",
|
||||
"new-version",
|
||||
|
|
@ -284,6 +290,7 @@
|
|||
"apple submit": [
|
||||
"build",
|
||||
"env-file",
|
||||
"first-release",
|
||||
"keychain",
|
||||
"locale-map",
|
||||
"new-version",
|
||||
|
|
@ -758,6 +765,7 @@
|
|||
"alpha",
|
||||
"beta",
|
||||
"env-file",
|
||||
"first-release",
|
||||
"fraction",
|
||||
"include-drafts",
|
||||
"internal",
|
||||
|
|
@ -821,6 +829,7 @@
|
|||
"play upload": [
|
||||
"env-file",
|
||||
"evidence",
|
||||
"first-release",
|
||||
"include-drafts",
|
||||
"name",
|
||||
"not-sent-for-review",
|
||||
|
|
@ -1205,6 +1214,7 @@
|
|||
"expo",
|
||||
"fields",
|
||||
"file",
|
||||
"first-release",
|
||||
"flavor",
|
||||
"floor",
|
||||
"flutter",
|
||||
|
|
@ -1514,10 +1524,10 @@
|
|||
],
|
||||
"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] [--yes]"
|
||||
"storebound apple release --manual-release [--version 1.2.0] [--first-release] [--yes] [--json]"
|
||||
],
|
||||
"apple resubmit": [
|
||||
"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]"
|
||||
],
|
||||
"apple review-details": [
|
||||
"storebound apple review-details [--contact-from <Apple ID of another app>] [--dry]"
|
||||
|
|
@ -1688,7 +1698,7 @@
|
|||
"storebound play products [--dry] [--only id1,id2] [--yes] [--allow-price-changes]"
|
||||
],
|
||||
"play release": [
|
||||
"storebound play release app-release.aab [--internal completed] [--production draft] [--name \"1.0.0 (3)\"] [--dry]",
|
||||
"storebound play release app-release.aab [--internal completed] [--production draft] [--name \"1.0.0 (3)\"] [--first-release] [--dry]",
|
||||
"storebound play release --version-code 3 --production draft",
|
||||
"storebound play release app.aab --production completed --not-sent-for-review (commit with changesNotSentForReview=true)"
|
||||
],
|
||||
|
|
@ -1705,7 +1715,7 @@
|
|||
"storebound play subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--yes] [--allow-price-changes]"
|
||||
],
|
||||
"play upload": [
|
||||
"storebound play upload [<app.aab>] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--pre-check \"<cmd with {file}>\"] [--evidence <path.json>] [--dry] [--yes] [--json]"
|
||||
"storebound play upload [<app.aab>] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--first-release] [--pre-check \"<cmd with {file}>\"] [--evidence <path.json>] [--dry] [--yes] [--json]"
|
||||
],
|
||||
"prices": [
|
||||
"storebound prices export [--out store/prices.csv] [--store apple|play] [--only ids] [--with-automatic] [--no-subscriptions] [--dry] [--json]",
|
||||
|
|
|
|||
|
|
@ -617,7 +617,7 @@ ${secs.join('\n\n')}
|
|||
const ACT: Record<string, string> = {
|
||||
app_store_connect_api_key: 'ASC_KEY_ID / ASC_ISSUER_ID / ASC_P8 in .storebound/credentials.json (the .p8 path, never its contents)',
|
||||
upload_to_app_store: '`storebound apple listing`, `apple screenshots`, `apple previews` (dry run first; submitting stays with the owner)', deliver: '`storebound apple listing` / `apple screenshots`',
|
||||
upload_to_play_store: '`storebound play listing`, `play images`, `play release <aab>` (testing tracks; production is the owner\'s)', supply: '`storebound play listing` / `play images` / `play release`',
|
||||
upload_to_play_store: '`storebound play listing`, `play images`, `play release <aab>` (testing tracks; production updates of a published app: --production completed --yes; the first-ever release is the owner\'s)', supply: '`storebound play listing` / `play images` / `play release`',
|
||||
upload_to_testflight: '`storebound apple testflight` (testflight skill)', pilot: '`storebound apple testflight` (testflight skill)',
|
||||
match: 'keep match for signing', sync_code_signing: 'keep match for signing',
|
||||
snapshot: '`storebound shots capture --source ios-sim`', capture_screenshots: '`storebound shots capture --source ios-sim`', capture_ios_screenshots: '`storebound shots capture --source ios-sim`',
|
||||
|
|
|
|||
68
cli/lib/release-kind.ts
Normal file
68
cli/lib/release-kind.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// Update or first release? The owner's standing rule (docs/bypass.md, "Updates are agent steps"): a new version of an
|
||||
// app that is ALREADY on the store ships end to end without an owner gate (submit for review, promote to production,
|
||||
// roll out, release an approved version) with --yes, also in a pipeline. The gate stays for the first-ever release of an
|
||||
// app (and, in their own commands, removal from sale and live price changes).
|
||||
// The kind comes from the store, never from the config:
|
||||
// Google Play the production track holds a completed / inProgress / halted release (the app was published)
|
||||
// App Store the platform has a version that is or was on sale (READY_FOR_SALE, READY_FOR_DISTRIBUTION,
|
||||
// REPLACED_WITH_NEW_VERSION)
|
||||
// A first release with --yes (nobody to answer a prompt) is refused with exit 3 `OWNER_STEP` unless the owner approved
|
||||
// it: `--first-release` after the owner's yes in chat, or bypass mode (docs/bypass.md). In a terminal without --yes the
|
||||
// normal confirmation prompt is the owner's approval.
|
||||
import { bypassOn } from './bypass';
|
||||
import type { Ctx } from './ctx';
|
||||
import { declined } from './ctx';
|
||||
import { log } from './log';
|
||||
import { SHIP_EXIT, ShipStepError, shipRun } from './ship-step';
|
||||
|
||||
export type ReleaseKind = 'update' | 'first';
|
||||
export interface KindAnswer { kind: ReleaseKind; why: string }
|
||||
|
||||
/** Play: the production track's releases say whether the app was ever published. Pure. */
|
||||
export function playReleaseKind(production: { status?: string; versionCodes?: string[] }[] | undefined): KindAnswer {
|
||||
const live = (production ?? []).filter(r => ['completed', 'inProgress', 'halted'].includes(String(r.status)));
|
||||
if (live.length) return { kind: 'update', why: `the production track holds ${live.map(r => `${r.status} ${(r.versionCodes ?? []).join(',')}`).join('; ')}` };
|
||||
const drafts = (production ?? []).length;
|
||||
return { kind: 'first', why: drafts ? 'the production track has only draft releases: the app was never published' : 'the production track is empty: the app was never published' };
|
||||
}
|
||||
|
||||
/** App Store version states that prove the app was on sale. */
|
||||
export const APPLE_LIVE_STATES = ['READY_FOR_SALE', 'READY_FOR_DISTRIBUTION', 'REPLACED_WITH_NEW_VERSION'] as const;
|
||||
|
||||
/** App Store: the versions of one platform say whether the app was ever on sale. Pure. */
|
||||
export function appleReleaseKind(versions: { versionString?: string; state: string }[]): KindAnswer {
|
||||
const live = versions.filter(v => (APPLE_LIVE_STATES as readonly string[]).includes(v.state));
|
||||
if (live.length) return { kind: 'update', why: `${live.map(v => `${v.versionString ?? '?'} ${v.state}`).join(', ')} on this platform` };
|
||||
return { kind: 'first', why: 'no version of this app was ever on sale on this platform' };
|
||||
}
|
||||
|
||||
export interface GateInput { kind: ReleaseKind; yes: boolean; bypass: boolean; approved: boolean }
|
||||
/** Whether the step may run. Updates always; a first release only with a person answering a prompt (no --yes), the
|
||||
* owner's `--first-release`, or bypass mode. Pure. */
|
||||
export function firstReleaseAllowed(g: GateInput): boolean {
|
||||
return g.kind === 'update' || !g.yes || g.approved || g.bypass;
|
||||
}
|
||||
|
||||
export const firstReleaseMessage = (store: 'play' | 'apple', what: string): string =>
|
||||
`first release of this app (${store === 'play' ? 'Google Play' : 'App Store'}): ${what} is the owner's step, approved once. Updates of an app that is already live need no approval. `
|
||||
+ 'After the owner\'s yes: re-run with --first-release (or turn on bypass mode: storebound settings bypass on)';
|
||||
|
||||
/**
|
||||
* Applies the policy to a real run. Returns true when the step may go on. A refusal is exit 3: error.code OWNER_STEP
|
||||
* under the ship-step contract (with the console deep link), the usual "declined" exit 3 otherwise.
|
||||
*/
|
||||
export function enforceFirstRelease(ctx: Ctx, k: KindAnswer, o: { store: 'play' | 'apple'; what: string; consoleUrl?: string }): boolean {
|
||||
const a = ctx.args;
|
||||
const bypass = bypassOn();
|
||||
// the desktop's owner action: the owner pressed the button of a native dialog that showed the dry run (cli/lib/ctx.ts confirm)
|
||||
const approved = a.has('first-release') || (process.env.STOREBOUND_SERVE === '1' && process.env.STOREBOUND_OWNER_CONFIRMED === '1');
|
||||
if (firstReleaseAllowed({ kind: k.kind, yes: a.has('yes'), bypass, approved })) {
|
||||
if (k.kind === 'update') log('release', `update of a live app (${k.why}): no owner step needed`);
|
||||
else log('release', `first release (${k.why}): ${approved ? 'approved by the owner' : bypass ? 'bypass mode' : 'confirmation prompt'}`);
|
||||
return true;
|
||||
}
|
||||
const msg = `${firstReleaseMessage(o.store, o.what)} (${k.why})`;
|
||||
if (shipRun()) throw new ShipStepError(msg, SHIP_EXIT.owner, 'OWNER_STEP', o.consoleUrl);
|
||||
declined(msg);
|
||||
return false;
|
||||
}
|
||||
|
|
@ -286,7 +286,7 @@ export function resubmitRows(s: SubmitState, plan: SubmitPlan, build: BuildStep,
|
|||
}
|
||||
for (const it of plan.items) rows.push({ step: 'item', what: it.label, now: it.devRejected ? 'DEVELOPER_REJECTED' : '-', action: addAction(it) });
|
||||
if (ch?.action === 'reuse') for (const d of ch.strayDrafts) rows.push({ step: 'stray draft', what: d.id, now: `${d.state}, ${d.items.length} item(s)`, action: d.items.length ? 'left as it is (named)' : `left as it is: ${EMPTY_DRAFT}` });
|
||||
if (ch?.action !== 'busy') rows.push({ step: 'submit', what: 'PATCH submitted: true', now: '-', action: 'owner step (--yes); retried while Apple says "not ready yet" and no item is left REJECTED' });
|
||||
if (ch?.action !== 'busy') rows.push({ step: 'submit', what: 'PATCH submitted: true', now: '-', action: 'send with --yes (update of an app on sale: no owner step; the first-ever release: the owner approves it once); retried while Apple says "not ready yet" and no item is left REJECTED' });
|
||||
return rows;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,10 +29,11 @@ export const SHIP_STEPS: Record<string, { step: string; store: 'apple' | 'play'
|
|||
'apple upload': { step: 'upload', store: 'apple' },
|
||||
'apple submit': { step: 'submit', store: 'apple' },
|
||||
'apple resubmit': { step: 'resubmit', store: 'apple' },
|
||||
'apple release': { step: 'release', store: 'apple' },
|
||||
};
|
||||
|
||||
/** error.code of an exit 3: what the owner has to do. */
|
||||
export const OWNER_CODES = ['AGREEMENT_PENDING', 'SIGN_IN_REQUIRED', 'COMPLIANCE_ANSWER_MISSING', 'CONSOLE_STEP', 'CONFIRMATION_REQUIRED'] as const;
|
||||
export const OWNER_CODES = ['AGREEMENT_PENDING', 'SIGN_IN_REQUIRED', 'COMPLIANCE_ANSWER_MISSING', 'CONSOLE_STEP', 'CONFIRMATION_REQUIRED', 'OWNER_STEP'] as const;
|
||||
|
||||
export interface ShipErrorInfo { code: string; message: string; consoleUrl?: string }
|
||||
export interface ShipResult {
|
||||
|
|
@ -155,13 +156,14 @@ export function fieldsFromDetail(key: string, d: unknown): Fields {
|
|||
const b = x.build as { number?: string } | undefined;
|
||||
return { version: str(v?.versionString), build: str(b?.number), state: dry ? 'planned' : 'WAITING_FOR_REVIEW' };
|
||||
}
|
||||
case 'apple release': return { version: str(x.version), state: dry ? 'planned' : str(x.state) };
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_URL: Record<string, ConsolePage> = {
|
||||
'play build': 'play.bundle-explorer', 'play upload': 'play.releases', 'play release': 'play.releases',
|
||||
'apple build': 'asc.testflight', 'apple upload': 'asc.testflight', 'apple submit': 'asc.app-review', 'apple resubmit': 'asc.app-review',
|
||||
'apple build': 'asc.testflight', 'apple upload': 'asc.testflight', 'apple submit': 'asc.app-review', 'apple resubmit': 'asc.app-review', 'apple release': 'asc.version',
|
||||
};
|
||||
|
||||
/** The final result: the contract keys, then the command's own --json fields that do not clash with them (so readers
|
||||
|
|
@ -297,6 +299,7 @@ const VERSION_FLAGS: Record<string, { version?: string; build?: string; intBuild
|
|||
'apple upload': { build: 'build' },
|
||||
'apple submit': { version: 'version', build: 'build' },
|
||||
'apple resubmit': { version: 'version', build: 'build' },
|
||||
'apple release': { version: 'version' },
|
||||
};
|
||||
|
||||
/** The argv with the flags of --version-from added (a flag given by hand must agree with the file). */
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ export const TOOLS: ToolDef[] = [
|
|||
description: 'Replace the whole Play Data safety form with the answers from the config (CSV import). Skipped when the CSV equals the last upload of this project (hash in .storebound/; force: true sends it anyway). dry_run prints the CSV.',
|
||||
props: { force: { type: 'boolean', default: false, description: 'Upload even when the CSV equals the last upload (after a manual change in Play Console)' } }, args: i => flag('force', i.force) },
|
||||
{ name: 'play_release_testing', title: 'Release to a Play testing track', command: ['play', 'release'], write: true, network: true,
|
||||
description: 'Upload an AAB (or reuse a versionCode) to a TESTING track (internal, alpha, beta) with release notes. Production is not available here.',
|
||||
description: 'Upload an AAB (or reuse a versionCode) to a TESTING track (internal, alpha, beta) with release notes. Production is not available through MCP: an update of a published app is `storebound play release <aab> --production completed --yes` in a terminal or pipeline (no owner step), the first-ever production release is the owner\'s.',
|
||||
props: { aab: { type: 'string', description: 'Path to the .aab' }, version_code: { type: 'integer', minimum: 1 },
|
||||
track: { type: 'string', enum: ['internal', 'alpha', 'beta'], default: 'internal' }, status: { type: 'string', enum: ['completed', 'draft'], default: 'completed' }, not_sent_for_review: notSentProp },
|
||||
args: i => [...(i.aab ? [String(i.aab)] : []), ...opt('version-code', i.version_code), `--${String(i.track ?? 'internal')}`, String(i.status ?? 'completed'), ...flag('not-sent-for-review', i.not_sent_for_review)] },
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
// Upload an AAB (or reuse an uploaded versionCode) and put it on one or more tracks in one edit, with the "What's new"
|
||||
// of every listing language as release notes.
|
||||
// storebound play release app-release.aab [--internal completed] [--production draft] [--name "1.0.0 (3)"] [--dry]
|
||||
// storebound play release app-release.aab [--internal completed] [--production draft] [--name "1.0.0 (3)"] [--first-release] [--dry]
|
||||
// storebound play release --version-code 3 --production draft
|
||||
// storebound play release app.aab --production completed --not-sent-for-review (commit with changesNotSentForReview=true)
|
||||
// Tracks: internal, alpha (closed), beta (open), production; status completed | draft | inProgress | halted.
|
||||
// Without a track flag: internal, completed. A production release that is not a draft asks for confirmation.
|
||||
// Without a track flag: internal, completed. A production release that is not a draft asks for confirmation (--yes in
|
||||
// scripts). An UPDATE of a published app (the production track holds a completed / inProgress / halted release) needs
|
||||
// no owner step: `play release <aab> --production completed --yes` runs in a pipeline (cli/lib/release-kind.ts). The
|
||||
// first-ever production release with --yes is exit 3 OWNER_STEP unless the owner approved it (--first-release, or
|
||||
// bypass mode).
|
||||
// Draft app (never published): Play only accepts status "draft" on any track; the first production release is sent
|
||||
// from the Console (see the play-first-release skill).
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
|
|
@ -19,6 +23,7 @@ import { usedVersionCodes } from './next-version-code';
|
|||
import { readPlayCopy, refusePlayNotes } from './listing';
|
||||
import { shipNoop, shipPhase, shipReport } from '../lib/ship-step';
|
||||
import { consoleUrl, playTrackPage } from '../lib/console-links';
|
||||
import { enforceFirstRelease, playReleaseKind } from '../lib/release-kind';
|
||||
|
||||
const TRACKS = ['internal', 'alpha', 'beta', 'production'];
|
||||
|
||||
|
|
@ -49,8 +54,20 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
for (const t of tracks) if (!STATUSES.includes(t.status)) throw new Error(`--${t.track}: status must be one of ${STATUSES.join(', ')}`);
|
||||
const fraction = a.get('fraction');
|
||||
const live = tracks.find(t => t.track === 'production' && t.status !== 'draft');
|
||||
const gated = tracks.find(t => t.track === 'production' && (t.status === 'completed' || t.status === 'inProgress'));
|
||||
const notes = readPlayCopy(ctx).filter(c => c.whatsNew).map(c => ({ language: c.lang, text: c.whatsNew }));
|
||||
refusePlayNotes(ctx, notes, `play release notes${a.has('dry') ? ' (dry run)' : ''}`);
|
||||
// update of a published app: no owner step; the first production release: the owner's (exit 3 OWNER_STEP with --yes)
|
||||
let early: Awaited<ReturnType<typeof playClient>> | undefined;
|
||||
const client = async (): Promise<Awaited<ReturnType<typeof playClient>>> => (early ??= await playClient(root, pkg));
|
||||
let kind: ReturnType<typeof playReleaseKind> | null = null;
|
||||
if (gated) {
|
||||
const p0 = await client();
|
||||
const prod = await p0.read(async id => (await p0.call<{ releases?: PlayTrackRelease[] }>('GET', `${p0.API}/edits/${id}/tracks/production`).catch(() => ({ releases: [] }))).releases ?? []);
|
||||
kind = playReleaseKind(prod);
|
||||
if (a.has('dry')) log('play', `${kind.kind === 'update' ? 'update of a published app' : 'FIRST production release (the owner approves it once: --first-release)'}: ${kind.why}`);
|
||||
else if (!enforceFirstRelease(ctx, kind, { store: 'play', what: 'the first production release', consoleUrl: consoleUrl('play.publishing') })) return;
|
||||
}
|
||||
if (live && !a.has('dry') && !(await confirm(ctx, `Release to PRODUCTION as "${live.status}" (public once Google approves)?`))) return;
|
||||
|
||||
if (aab) {
|
||||
|
|
@ -59,7 +76,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (sdkWhy) throw new Error(`${aab}: not released, ${sdkWhy}`);
|
||||
}
|
||||
shipReport({ track: tracks.map(t => t.track).join(','), url: consoleUrl(playTrackPage(tracks.length === 1 ? tracks[0]!.track : undefined)) });
|
||||
const play = await playClient(root, pkg);
|
||||
const play = await client();
|
||||
let released = fixed ?? '';
|
||||
let nothing = false;
|
||||
try {
|
||||
|
|
@ -116,6 +133,6 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
}
|
||||
// machine-readable result for CI scripts (a Jenkins stage reads it instead of calling the Play API itself)
|
||||
if (a.has('json')) printJson({ versionCode: released ? Number(released) : null, sha256: aab ? createHash('sha256').update(readFileSync(aab)).digest('hex') : null, name: a.get('name') ?? null,
|
||||
tracks: tracks.map(t => ({ track: t.track, status: t.status, userFraction: t.status === 'inProgress' ? Number(fraction ?? 0.1) : null })), dry: a.has('dry') });
|
||||
tracks: tracks.map(t => ({ track: t.track, status: t.status, userFraction: t.status === 'inProgress' ? Number(fraction ?? 0.1) : null })), dry: a.has('dry'), ...(kind ? { releaseKind: kind.kind } : {}) });
|
||||
done('play', 'release done');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
// Upload an Android App Bundle to a Google Play testing track.
|
||||
// storebound play upload [<app.aab>] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--pre-check "<cmd with {file}>"] [--evidence <path.json>] [--dry] [--yes] [--json]
|
||||
// Upload an Android App Bundle to a Google Play track (testing tracks, or production for an UPDATE of a published app).
|
||||
// storebound play upload [<app.aab>] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--first-release] [--pre-check "<cmd with {file}>"] [--evidence <path.json>] [--dry] [--yes] [--json]
|
||||
// Default AAB: the newest in build/android (storebound play build), or with --version-code N the one with that
|
||||
// versionCode. Package and versionCode are read from the bundle; the upload is refused when Play already has that
|
||||
// versionCode (exit 1), and under the ship-step contract (--json / --result-file, docs/ship-step.md) it is exit 7
|
||||
// "nothing to do" with the track and state that are there: never a second upload. Release notes: the "What's new" of
|
||||
// every listing language. --dry is read-only (one throwaway edit: version codes and the track are read, the edit is
|
||||
// deleted). A real run asks first (--yes in scripts). --track production sends the release to Google's review (a
|
||||
// pipeline's update step); --status draft keeps it for the Console.
|
||||
// pipeline's update step): no owner gate when the production track already holds a published release (an update of
|
||||
// a live app, cli/lib/release-kind.ts); a first-ever production release with --yes is exit 3 OWNER_STEP unless the owner
|
||||
// approved it (--first-release, or bypass mode). --status draft keeps the release for the Console.
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
|
|
@ -21,6 +23,7 @@ import { usedVersionCodes } from './next-version-code';
|
|||
import { runPreCheck } from '../lib/pre-check';
|
||||
import { shipNoop, shipPhase, shipReport, shipRun, shipWarn } from '../lib/ship-step';
|
||||
import { consoleUrl, playTrackPage } from '../lib/console-links';
|
||||
import { enforceFirstRelease, playReleaseKind } from '../lib/release-kind';
|
||||
|
||||
const TAG = 'upload';
|
||||
export const UPLOAD_TRACKS = ['internal', 'alpha', 'beta', 'production'] as const;
|
||||
|
|
@ -83,11 +86,15 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (shipRun()) { done(TAG, `versionCode ${info.versionCode} is on Google Play already: nothing uploaded`); return; }
|
||||
}
|
||||
if (used.includes(info.versionCode)) throw new Error(`versionCode ${info.versionCode} is already on Google Play: rebuild with --version-code ${nextVersionCode(used)} (storebound play build)`);
|
||||
const summary = { aab: relative(root, aab), packageName: info.packageName, versionCode: info.versionCode, versionName: info.versionName ?? null, sizeMB: Math.round(size / 104857.6) / 10,
|
||||
// production: update of a published app (no owner gate) or the first release (the owner's step)
|
||||
const kind = track === 'production' && status !== 'draft' ? playReleaseKind(current) : null;
|
||||
if (kind && !dry && !enforceFirstRelease(ctx, kind, { store: 'play', what: 'the first production release', consoleUrl: consoleUrl('play.publishing') })) return;
|
||||
const summary = { ...(kind ? { releaseKind: kind.kind } : {}), aab: relative(root, aab), packageName: info.packageName, versionCode: info.versionCode, versionName: info.versionName ?? null, sizeMB: Math.round(size / 104857.6) / 10,
|
||||
track, status, notes: notes.map(n => n.language), replaces: current.map(r => `${r.status} ${(r.versionCodes ?? []).join(',')}`), ...(pre ? { preCheck: pre } : {}) };
|
||||
if (dry) {
|
||||
if (json) { printJson(summary); return; }
|
||||
log(TAG, `${summary.aab}: ${info.packageName} ${info.versionName ?? ''} (${info.versionCode}), ${summary.sizeMB} MB, versionCode free`);
|
||||
if (kind) log(TAG, `${kind.kind === 'update' ? 'update of a published app' : 'FIRST production release (the owner approves it once: --first-release)'}: ${kind.why}`);
|
||||
log(TAG, `would upload it and set track ${track} to ${status} ${info.versionCode}${notes.length ? `, notes in ${notes.length} language(s)` : ', no release notes'}${current.length ? ` (now on ${track}: ${summary.replaces.join('; ')})` : ''}`);
|
||||
done(TAG, 'upload planned (dry run, read-only)');
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ export const DESKTOP_ONLY: ToolDef[] = [
|
|||
version_name: { type: 'string', pattern: '^[0-9A-Za-z.+-]{1,40}$', description: 'versionName (default: from the project)' } },
|
||||
args: i => [...opt('flavor', i.flavor), ...opt('module', i.module), ...opt('version-name', i.version_name)] },
|
||||
{ name: 'play_upload', title: 'Upload an AAB to a testing track', command: ['play', 'upload'], write: true, network: true,
|
||||
description: 'Uploads the newest AAB of the build output to a Google Play testing track (internal, closed or open testing), versionCode checked first. Testers of that track get it; production stays an owner action.',
|
||||
description: 'Uploads the newest AAB of the build output to a Google Play testing track (internal, closed or open testing), versionCode checked first. Testers of that track get it. Production: an update of a published app is `storebound play upload --track production --yes` in a terminal or pipeline (no owner step); the first-ever production release stays the owner\'s.',
|
||||
props: { track: { type: 'string', enum: ['internal', 'alpha', 'beta'], default: 'internal' }, status: { type: 'string', enum: ['completed', 'draft'], default: 'completed' },
|
||||
name: { type: 'string', pattern: '^[^\\x00-\\x1f]{1,50}$', description: 'Release name (default: the version)' } },
|
||||
args: i => [...opt('track', i.track), ...opt('status', i.status), ...opt('name', i.name)] },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue