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

@ -20,6 +20,12 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha
testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final
Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For
these you prepare everything, show the dry run, and the owner presses one button or says yes.
**Updates are agent steps:** a new version of an app that is already on the store (Play: the production track holds
a published release; App Store: a version of the platform is or was on sale) is yours end to end, no owner gate
per update: `storebound play release <aab> --production completed --yes`, `storebound play upload --track
production --yes`, `storebound apple submit --build <n> --yes`, `storebound apple release --manual-release --yes`
(docs/bypass.md). Only the **first-ever release** of an app stays the owner's (exit 3 `OWNER_STEP`: after the
owner's yes re-run with `--first-release`), plus removal from sale, live price changes and anything that costs money.
**Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too:
MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm <operationId>`, and
console plans' `buttons.final`. Say in one line what you pressed; every action is logged to

View file

@ -20,6 +20,12 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha
testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final
Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For
these you prepare everything, show the dry run, and the owner presses one button or says yes.
**Updates are agent steps:** a new version of an app that is already on the store (Play: the production track holds
a published release; App Store: a version of the platform is or was on sale) is yours end to end, no owner gate
per update: `storebound play release <aab> --production completed --yes`, `storebound play upload --track
production --yes`, `storebound apple submit --build <n> --yes`, `storebound apple release --manual-release --yes`
(docs/bypass.md). Only the **first-ever release** of an app stays the owner's (exit 3 `OWNER_STEP`: after the
owner's yes re-run with `--first-release`), plus removal from sale, live price changes and anything that costs money.
**Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too:
MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm <operationId>`, and
console plans' `buttons.final`. Say in one line what you pressed; every action is logged to

View file

@ -21,6 +21,12 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha
testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final
Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For
these you prepare everything, show the dry run, and the owner presses one button or says yes.
**Updates are agent steps:** a new version of an app that is already on the store (Play: the production track holds
a published release; App Store: a version of the platform is or was on sale) is yours end to end, no owner gate
per update: `storebound play release <aab> --production completed --yes`, `storebound play upload --track
production --yes`, `storebound apple submit --build <n> --yes`, `storebound apple release --manual-release --yes`
(docs/bypass.md). Only the **first-ever release** of an app stays the owner's (exit 3 `OWNER_STEP`: after the
owner's yes re-run with `--first-release`), plus removal from sale, live price changes and anything that costs money.
**Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too:
MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm <operationId>`, and
console plans' `buttons.final`. Say in one line what you pressed; every action is logged to

View file

@ -3,6 +3,8 @@
## Unreleased
- Updates are agent steps (the owner's standing rule), first releases stay the owner's. `play release <aab> --production completed|inProgress --yes`, `play upload --track production --yes`, `apple submit --build <n> --yes` and `apple release --manual-release --yes` run without an owner gate, in a terminal and in a pipeline, when the store says the app is already live (Play: the production track holds a completed / inProgress / halted release; App Store: a version of the platform is or was `READY_FOR_SALE` / `READY_FOR_DISTRIBUTION` / `REPLACED_WITH_NEW_VERSION`; `cli/lib/release-kind.ts`). The result line carries `releaseKind`; a re-run is exit 7. The first-ever release of an app with `--yes` is exit 3 `OWNER_STEP` ("first release of this app: the owner approves it once"), approved by `--first-release` (after the owner's yes) or bypass mode; a person answering the prompt (no `--yes`) is the approval too. `apple release` is now a ship step (`--json`, `--result-file`, exit 7 when the version is released already). Policy in docs/bypass.md ("Updates are agent steps"), docs/ship-step.md, the release-management and play-first-release skills, AGENTS.md; pitfall 123 / 96.
## 0.8.52 · 2026-10-26
- `analytics snapshot` and `reports` for a collector: a store-only config may name the App Store app by `apple.appId` alone (`bundleId` null or absent; every write still needs it); the Play reports bucket also comes from env `PLAY_REPORTS_BUCKET` or the credential profile's `reportsBucket` when `play.reportsBucket` is missing; `analytics snapshot --json` prints the snapshot as one JSON document on stdout (also when every store failed, with the exit code 1) and `--no-write` skips the file.

View file

@ -19,6 +19,12 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha
testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final
Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For
these you prepare everything, show the dry run, and the owner presses one button or says yes.
**Updates are agent steps:** a new version of an app that is already on the store (Play: the production track holds
a published release; App Store: a version of the platform is or was on sale) is yours end to end, no owner gate
per update: `storebound play release <aab> --production completed --yes`, `storebound play upload --track
production --yes`, `storebound apple submit --build <n> --yes`, `storebound apple release --manual-release --yes`
(docs/bypass.md). Only the **first-ever release** of an app stays the owner's (exit 3 `OWNER_STEP`: after the
owner's yes re-run with `--first-release`), plus removal from sale, live price changes and anything that costs money.
**Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too:
MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm <operationId>`, and
console plans' `buttons.final`. Say in one line what you pressed; every action is logged to

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') {

View file

@ -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'],

View file

@ -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]",

View file

@ -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
View 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;
}

View file

@ -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;
}

View file

@ -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). */

View 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)] },

View file

@ -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');
}

View file

@ -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;

View file

@ -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)] },

View file

@ -15,6 +15,12 @@ storebound plus you, the AI agent, do the work. The owner is not your pair of ha
testers. (Also the owner's: canceling a submission, starting or stopping a store experiment, a console form's final
Submit / Create that a console plan lists in `buttons.final`, signing in and granting account permissions.) For
these you prepare everything, show the dry run, and the owner presses one button or says yes.
**Updates are agent steps:** a new version of an app that is already on the store (Play: the production track holds
a published release; App Store: a version of the platform is or was on sale) is yours end to end, no owner gate
per update: `storebound play release <aab> --production completed --yes`, `storebound play upload --track
production --yes`, `storebound apple submit --build <n> --yes`, `storebound apple release --manual-release --yes`
(docs/bypass.md). Only the **first-ever release** of an app stays the owner's (exit 3 `OWNER_STEP`: after the
owner's yes re-run with `--first-release`), plus removal from sale, live price changes and anything that costs money.
**Bypass mode** (`storebound settings bypass status`): when the owner turned it on, you run these owner steps too:
MCP `owner_step` (dry run first, then `dry_run: false`), `storebound api ... --yes --confirm <operationId>`, and
console plans' `buttons.final`. Say in one line what you pressed; every action is logged to

View file

@ -11,9 +11,34 @@ storebound settings bypass off # back to the owner pressing every final butt
It is stored in `~/.storebound/settings.json` (this machine only), never in a project's config or repo.
## Updates are agent steps (no bypass needed)
The owner's standing rule: a new version of an app that is **already on the store** ships end to end by the agent or the
pipeline, with no owner gate per update: submit for review, promote to production, roll out, release an approved
version. This holds with bypass mode off, in a terminal and in CI:
| Store | Update commands (all take `--yes`; `--json` / `--result-file` for the [ship-step contract](ship-step.md)) |
|---|---|
| Google Play | `storebound play release <app.aab> --production completed --yes` (or `--production inProgress --fraction 0.05`), or `storebound play upload <app.aab> --track production --yes` |
| App Store | `storebound apple submit --build <n> --yes`, then (manual release) `storebound apple release --manual-release --yes` |
What makes it an update is read from the store, never from the config: Play's production track holds a completed /
inProgress / halted release; the App Store platform has a version that is or was `READY_FOR_SALE`
(`READY_FOR_DISTRIBUTION`, `REPLACED_WITH_NEW_VERSION`). The result line carries `releaseKind: "update"`.
Still the owner's, bypass mode or not (the commands refuse with exit 3, `error.code` `OWNER_STEP`, "first release of this
app"): the **first-ever release** of an app (the first production release on Play, the first submission or release on
the App Store), removal from sale, live price changes and anything that costs money. The owner approves a first
release once: after the owner's yes in chat re-run with `--first-release` (or turn on bypass mode). A person at a
terminal answering the normal confirmation prompt (no `--yes`) is the owner's approval too, and so is the desktop's
owner dialog. Re-running an update that is on the store already is exit 7, nothing sent.
MCP is unchanged: it still does not offer `--yes` or `--production`; an update goes through the CLI in a terminal or
pipeline (or `owner_step` in bypass mode).
## What it unlocks
- MCP: the `owner_step` tool appears. It runs one of: `apple submit`, `apple resubmit`, `apple iap-submit`, `apple release`,
- MCP: the `owner_step` tool appears. It runs one of (a first release also passes the gate in bypass mode): `apple submit`, `apple resubmit`, `apple iap-submit`, `apple release`,
`apple preorder`, `play release`, `play rollout`, `reviews` (replies), `apple testflight` (testers), `play products`,
`play subscriptions`, `apple iap`, `apple subscriptions`, `prices` (live prices). Dry run first (`dry_run` defaults to
true), then `dry_run: false` runs it with `--yes`.

View file

@ -9,6 +9,7 @@ fails without the fix.
| # | date | lesson | guard |
|---|---|---|---|
| 96 | 2026-10 | A pipeline could not ship a production update of a live app: production was treated as the owner's for every release, though the owner's rule is updates end to end and only the first-ever release gated. | `cli/lib/release-kind.ts` reads update vs first from the store; `--yes` ships an update (Play `play release --production` / `play upload --track production`, App Store `apple submit` / `apple release`), a first release with `--yes` is exit 3 `OWNER_STEP` (`--first-release` or bypass approve it), a re-run is exit 7; `tests/update-gate.test.ts` |
| 95 | 2026-10 | The Google service-account keys moved to the owner's credential broker, but storebound still wanted a local `PLAY_SERVICE_ACCOUNT`, and a Play write with a missing permission ran its edit before Google's bare 403. | `cli/lib/broker.ts` (broker.json, key file mode 600, token cache and single flight, grants, `playPreflight` before Play write commands), `playToken` in `cli/lib/google-auth.ts` (broker first, local key fallback when it does not answer), `cli/lib/google-access.ts` (403 SERVICE_DISABLED / PERMISSION_DENIED -> exact API or grant with its link, never retried), `validate` blockers; `tests/credential-broker.test.ts` against a local fake broker. Skill pitfall 122, docs/credential-broker.md. |
| 94 | 2026-10 | An app with managed publishing was approved but never went live: `play status` said "In review / ready to publish" with no next step, and the Publishing overview "Publish N changes" button (production + Data safety) was easy to miss or to confuse with Discard changes. | `console plan play-publish-changes` (final "Publish N changes" is the owner's, Discard changes / Stop managed publishing never-press), `console verify play-publish-changes` (API), the hint in `play status`, `status` and the desktop Releases tab (`cli/lib/play-publish.ts`, `tests/console-play-plans.test.ts`). |
| 93 | 2026-10 | A git-ignored, never-captured frames folder rendered store images with no app or game in them; render only logged 404s and `shots check` said 0 errors. | `shots render` refuses to start while a scene frame is missing (`framesForRender` in `cli/shots/lib.ts`) and fails when any load failed; report.json `missing` is a `frame-missing` error in `shots check` / `validate` (`cli/shots/report.ts`, `tests/shots-missing-frames.test.ts`). |

View file

@ -14,6 +14,7 @@ commands below keep their normal behaviour in a terminal; the contract switches
| `storebound apple upload` | `upload` | `apple` |
| `storebound apple submit` | `submit` | `apple` |
| `storebound apple resubmit` | `resubmit` | `apple` |
| `storebound apple release` (`--manual-release`) | `release` | `apple` |
A write still needs `--yes` in a pipeline: without it the confirmation is declined and the run ends with exit 3
(`CONFIRMATION_REQUIRED`). `apple submit` without `--yes` is a dry run (exit 0, `state: "planned"`).
@ -25,7 +26,7 @@ A write still needs `--yes` in a pipeline: without it the confirmation is declin
| 0 | ok | - |
| 1 | failure (build failed, network, timeout, blockers) | `FAILED`, `TIMEOUT`, `BLOCKERS` |
| 2 | usage (unknown flag, bad value, bad `--version-from` / `--env-file`) | `USAGE`, `ENV_FILE` |
| 3 | needs the owner | `AGREEMENT_PENDING`, `SIGN_IN_REQUIRED`, `COMPLIANCE_ANSWER_MISSING`, `CONSOLE_STEP`, `CONFIRMATION_REQUIRED` |
| 3 | needs the owner | `AGREEMENT_PENDING`, `SIGN_IN_REQUIRED`, `COMPLIANCE_ANSWER_MISSING`, `CONSOLE_STEP`, `CONFIRMATION_REQUIRED`, `OWNER_STEP` |
| 4 | the store refused it (4xx answer, ITMS error, invalid build, rejected upload) | `STORE_REFUSED`, `BUILD_INVALID` |
| 7 | nothing to do: the build / release is on the store already (the result names what is there) | - |
@ -37,6 +38,31 @@ Console API access, Data safety, Content ratings, Publishing overview). The link
Note: `apple upload` and `apple testflight wait` keep their old exit 7 for a processing timeout when the contract is
off; under the contract a timeout is exit 1 `TIMEOUT` and an invalid build exit 4 `BUILD_INVALID`.
## Updates and first releases
The production and review steps (`play upload --track production`, `play release --production completed|inProgress`,
`apple submit`, `apple resubmit`, `apple release --manual-release`) read from the store whether this is an **update** of
an app that is already live or its **first release** (Play: the production track holds a completed / inProgress / halted
release; App Store: a version of the platform is or was on sale; `cli/lib/release-kind.ts`). The result carries
`releaseKind`.
- Update: `--yes` is all it takes. No owner gate, no bypass mode needed ([docs/bypass.md](bypass.md)).
- First release with `--yes`: exit 3, `error.code` `OWNER_STEP`, message "first release of this app ... the owner
approves it once", `error.consoleUrl` the page where the owner acts, nothing written. After the owner's yes: re-run
with `--first-release` (or bypass mode on).
- Already on the store (the versionCode on production with that status, the version submitted or released): exit 7,
nothing sent.
```sh
# Android production update
storebound play release build/android/app.aab --production completed --yes --json --result-file out/release.json --env-file /run/secrets/ship.env
# (or a staged rollout: --production inProgress --fraction 0.05 --yes)
# iOS update: attach the uploaded build, submit, and later release the approved version
storebound apple submit --build 42 --yes --json --result-file out/submit.json --env-file /run/secrets/ship.env
storebound apple release --manual-release --yes --json --result-file out/release.json --env-file /run/secrets/ship.env
case $? in 0|7) ;; 3) echo "owner: $(jq -r .error.consoleUrl out/release.json)"; exit 1 ;; *) exit 1 ;; esac
```
## `--json`: one final line
stdout carries exactly one line, at the end, also on failure:
@ -104,6 +130,7 @@ Reads the version and build for the step. Accepted shapes:
- `play release`: every track already holds the same release: exit 7, nothing committed.
- `apple submit`: the version is already submitted (or further) with this build, or the submission is in review:
exit 7.
- `apple release --manual-release`: the version is released (`READY_FOR_SALE` ...) and none waits for release: exit 7.
## `storebound status`

View file

@ -87,7 +87,9 @@ products) and answers the compliance facts no file proves, all in one batch at t
new app shows "Not selected": set `play.category` (Play's name, e.g. "Strategy"; `storebound check` warns
without it), then `storebound console plan play-store-settings`: /store-settings > the FIRST Edit > App or game >
the Category dropdown's `[role=option]` with exactly that name > Save. Contact details come from `play listing`.
12. **API + UI · Production.** `storebound play release --version-code N --production draft`, then in the Console
12. **API + UI · Production.** The first-ever production release is the owner's (`storebound play release ... --production
completed --yes` on a never-published app is exit 3 `OWNER_STEP`, `--first-release` after the owner's yes; once the app is published every later version is an
update the agent ships itself, `release-management`). `storebound play release --version-code N --production draft`, then in the Console
(you, with your browser tool):
a) `storebound console plan play-production-countries`: tracks/production > Countries / regions tab > Edit
countries / regions > the mat-checkbox "Select all rows" (or `play.countries`) > Save; a "Go to Publishing

View file

@ -14,8 +14,8 @@ presses release; you prepare, dry-run and report. `storebound mcp` deliberately
`--versions` lists every version the stores know. The same stages as the desktop's Releases tab.
- `storebound release plan 1.3.0` (MCP `release_plan`, read-only): what is left for that version on each store, in
fix order: config problems (`check`), the version on the store, store blockers (`validate`), the next move, warnings.
Owner steps (submit, release, raise / complete a rollout) are named with where the owner does them; you never run
them. "could not be read" means unknown, not "not there": fix the credentials first.
Owner steps (the first-ever submit / release of an app) are named with where the owner does them; updates of an app
on sale (submit, release, raise / complete a rollout) are yours, `--yes`. "could not be read" means unknown, not "not there": fix the credentials first.
- Many apps (a `storebound.workspace.json` above the app folders): the same commands cover every app, or `--app a,b`.
`storebound ws status` is the portfolio table across apps and stores.
@ -31,20 +31,26 @@ presses release; you prepare, dry-run and report. `storebound mcp` deliberately
teammate may have uploaded another one meanwhile.
2. `storebound apple release --phased on --dry`, then without `--dry` (works while the version is being prepared).
With versions on more than one App Store platform add `--platform ios|macos|tvos|visionos` (pitfall 79).
3. Submit (owner, UI). Wait: `storebound apple status --wait-review` (polls with backoff, exit 0 approved, 1 rejected,
3. Submit: an update of an app on sale is yours, `storebound apple submit --build <n> --yes` (exit 7 when it was sent
already); the first-ever submission is the owner's (exit 3 `OWNER_STEP`, re-run with `--first-release` after the
owner's yes). Wait: `storebound apple status --wait-review` (polls with backoff, exit 0 approved, 1 rejected,
7 timeout; `--timeout 72h`). Rejected: `review-rejections` skill.
4. Approved = PENDING_DEVELOPER_RELEASE. Owner releases: `storebound apple release --manual-release` (asks to confirm)
or the "Release This Version" button. With phased release on, automatic updates reach 1 %, 2 %, 5 %, 10 %, 20 %,
4. Approved = PENDING_DEVELOPER_RELEASE. Release an update yourself: `storebound apple release --manual-release --yes`
(the first-ever release of an app is the owner's: `OWNER_STEP`) or the "Release This Version" button. With phased release on, automatic updates reach 1 %, 2 %, 5 %, 10 %, 20 %,
50 %, 100 % over 7 days; anyone can still update manually from the store.
5. During the rollout watch crashes / reviews. Problem: `storebound apple release --phased pause` (up to 30 days in
total). Pausing stops automatic updates only; it is **not a rollback**: users who updated keep the build, and new
users still get the new version. The fix is a new build (expedited review is possible for critical bugs).
6. All good early: `storebound apple release --phased resume` / `--phased complete` (owner; asks to confirm).
6. All good early: `storebound apple release --phased resume --yes` / `--phased complete --yes` (an update's rollout is
yours).
## Google Play
1. `storebound play next-version-code` before building; the versionCode must be higher than every uploaded one.
2. Upload and start the rollout (owner confirms): `storebound play release app.aab --production inProgress --fraction 0.05`.
Draft apps cannot do this until the first release is reviewed (`play-first-release`).
2. Upload and start the rollout of an UPDATE (no owner step; the app is published): `storebound play release app.aab
--production inProgress --fraction 0.05 --yes` (`--production completed --yes` for everyone; or `storebound play
upload app.aab --track production --yes`). Re-running it is exit 7. The first-ever production release is the owner's
(exit 3 `OWNER_STEP`; after the owner's yes `--first-release`), and draft apps cannot do this until the first
release is reviewed (`play-first-release`).
Changes made in the Console (listing, App content) are sent from Publishing overview > "Submit N changes for
review". While a review is running that button opens "Do you want to restart your review?" with **Restart
review**: the running review starts over with the new changes. The owner's button, never the agent's; to keep the

View file

@ -1515,3 +1515,21 @@ that fails without the fix (see CONTRIBUTING.md).
- **Guard:** `cli/lib/broker.ts` (config, key file mode, token cache and single flight, grants, preflight, redaction),
`cli/lib/google-auth.ts` `playToken` (broker first, local key fallback), `cli/lib/google-access.ts` (403 ->
GoogleAccessError, no retry), `tests/credential-broker.test.ts` (a local fake broker).
## 123. Updates are agent steps: a pipeline could not ship a production update without the owner
- **Symptom:** a CI pipeline shipping a new version of an app that was long on the store was told that production is
"the owner's" (an AAB to production refused with "play upload is for testing tracks ... the owner sends it"), though
the owner's rule is that updates of a live app ship end to end without a confirmation per update.
- **Do:** an update is read from the store (Play: the production track holds a completed / inProgress / halted release;
App Store: a version of the platform is or was READY_FOR_SALE) and runs with `--yes`, in a terminal or a pipeline:
`storebound play release <aab> --production completed --yes` (or `--production inProgress --fraction 0.05`, or
`play upload --track production --yes`), `storebound apple submit --build <n> --yes`, `storebound apple release
--manual-release --yes`; `--json` / `--result-file` give the ship-step result (`releaseKind: "update"`), a re-run is
exit 7. Only the first-ever release of an app is the owner's: exit 3 `OWNER_STEP` "first release of this app", approved
once (`--first-release` after the owner's yes, or bypass mode). Removal from sale and live prices keep their own gates.
- **Never:** tell the owner to press a button for an update, pass `--first-release` without the owner's yes, or decide
update-or-first from the config instead of the store.
- **Guard:** `cli/lib/release-kind.ts` (the kind from the store, the policy), `tests/update-gate.test.ts` (fake stores:
update goes through, first release is exit 3, already there is exit 7), docs/bypass.md "Updates are agent steps".

View file

@ -265,6 +265,7 @@ describe('validate findings', () => {
describe('apple submit with a Game Center build (live 409 STATE_ERROR.BUILD_INDICATES_GAME_CENTER_ENABLED)', () => {
const fresh = () => {
s.asc.gameCenterBuild = true;
s.asc.put({ type: 'appStoreVersions', id: 'ver-0', attributes: { versionString: '0.9.0', appVersionState: 'READY_FOR_SALE', platform: 'IOS' }, rels: { app: APP } }); // an update, not a first release
s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } });
s.asc.put({ type: 'builds', id: 'b-8', attributes: { version: '8', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP } });
};

View file

@ -45,7 +45,11 @@ const CONFIG: ShipConfig = {
/** The live shape: version 1.0.0 REJECTED (Invalid Binary) with build 7, submission rs-old UNRESOLVED_ISSUES holding the
* version and the gems IAP; pro was never added; build 8 uploaded and VALID. */
/** The app was on sale before (an update, not a first release): version 0.9.0 READY_FOR_SALE. */
function onSale(): void { s.asc.put({ type: 'appStoreVersions', id: 'ver-0', attributes: { versionString: '0.9.0', appVersionState: 'READY_FOR_SALE', platform: 'IOS' }, rels: { app: APP } }); }
function rejected(o: { pro?: boolean; ver?: string; gems?: string } = {}): void {
onSale();
s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'REJECTED', platform: 'IOS' }, rels: { app: APP } });
s.asc.put({ type: 'builds', id: 'b-7', attributes: { version: '7', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP, appStoreVersion: 'ver-1' } });
s.asc.put({ type: 'builds', id: 'b-8', attributes: { version: '8', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP } });
@ -181,6 +185,7 @@ describe('apple resubmit --build <n>', () => {
describe('apple submit --build <n> (a first submission, the desktop owner action)', () => {
/** Version 1.0.0 in Prepare for Submission without a build, build 8 VALID, the gems IAP ready, no open submission. */
function fresh(): void {
onSale();
s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } });
s.asc.put({ type: 'builds', id: 'b-8', attributes: { version: '8', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP } });
s.asc.put({ type: 'inAppPurchases', id: 'iap-gems', attributes: { productId: 'com.example.notes.gems', inAppPurchaseType: 'CONSUMABLE', state: 'READY_TO_SUBMIT' }, rels: { app: APP } });
@ -199,7 +204,7 @@ describe('apple submit --build <n> (a first submission, the desktop owner action
expect(r.out).toMatch(/submission\s+new draft\s+-\s+create/);
expect(r.out).toMatch(/app version 1\.0\.0/);
expect(r.out).toContain('com.example.notes.gems');
expect(r.out).toContain('Submitting is the owner');
expect(r.out).toContain('update of an app on sale');
process.env.STOREBOUND_MACHINE = '1';
try {
const j = await capture(() => appleSubmit(p.ctx(['--build', '8', '--json'])));

View file

@ -235,6 +235,8 @@ describe('idempotent re-upload (fake stores)', () => {
const LISTING = '# Play\n\n## English\n<!-- play: en-US -->\n\n### Play short description\n```\nNotes.\n```\n\n### Play full description\n```\nNotes for everyone.\n```\n\n### What\'s new\n```\nFixes.\n```\n';
test('play upload: first run uploads (exit 0), the re-run is exit 7 with the existing release, no second upload', async () => {
s.play.live.bundles = [41];
s.play.live.tracks = { production: { releases: [{ status: 'completed', versionCodes: ['41'] }] } }; // published: an update
const p = project(BASE, { 'store/listing.md': LISTING, 'build/android/app.aab': makeAab('com.example.notes', 42, '1.2.0') });
const first = await ship('play upload', ['--json', '--yes', '--track', 'production'], a => playUpload({ loaded: p.loaded, args: a }));
expect(first.code).toBe(0);

259
tests/update-gate.test.ts Normal file
View file

@ -0,0 +1,259 @@
// Updates are agent steps, the first release is the owner's (cli/lib/release-kind.ts, docs/bypass.md "Updates are agent
// steps"): the kind (update / first) is read from the store; an update of a live app ships with --yes in a pipeline
// (no owner gate), a first-ever release with --yes is exit 3 OWNER_STEP unless approved (--first-release, bypass mode),
// and a release that is on the store already is exit 7. Against the stateful App Store Connect and Google Play doubles.
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
import { mkdtempSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { parseArgs } from '../cli/lib/args';
import { appleReleaseKind, firstReleaseAllowed, playReleaseKind } from '../cli/lib/release-kind';
import { setBypass } from '../cli/lib/bypass';
import { runShipStep } from '../cli/lib/ship-step';
import { run as playUpload } from '../cli/play/upload';
import { run as playRelease } from '../cli/play/release';
import { run as appleSubmit } from '../cli/apple/submit';
import { run as appleRelease } from '../cli/apple/release';
import { makeAab } from './build-fixtures';
import { ascEnv, BASE, playEnv, project, restore } from './helpers/store-mock';
import { installStores, type Stores, uninstallStores, writes } from './helpers/fake-stores';
const APP = '6400000101';
/** Runs a ship step in-process with stdout / stderr captured. */
async function ship(key: string, argv: string[], body: (a: ReturnType<typeof parseArgs>) => Promise<void>): Promise<{ code: number; out: string; err: string }> {
const out: string[] = [], err: string[] = [];
const o = process.stdout.write, e = process.stderr.write, cl = console.log, ce = console.error, cw = console.warn, ci = console.info;
process.stdout.write = ((s: string | Uint8Array) => { out.push(typeof s === 'string' ? s : new TextDecoder().decode(s)); return true; }) as typeof process.stdout.write;
process.stderr.write = ((s: string | Uint8Array) => { err.push(typeof s === 'string' ? s : new TextDecoder().decode(s)); return true; }) as typeof process.stderr.write;
console.log = (...a: unknown[]) => { out.push(a.map(String).join(' ') + '\n'); };
console.info = console.log;
console.error = (...a: unknown[]) => { err.push(a.map(String).join(' ') + '\n'); };
console.warn = console.error;
try {
const code = await runShipStep(key, argv, parseArgs, async a => {
const keep = console.log;
if (a.has('json')) console.log = (...x: unknown[]) => console.error(...x);
try { await body(a); } finally { console.log = keep; }
});
return { code, out: out.join(''), err: err.join('') };
} finally { process.stdout.write = o; process.stderr.write = e; console.log = cl; console.error = ce; console.warn = cw; console.info = ci; }
}
describe('the kind comes from the store (pure)', () => {
test('Play: a completed / inProgress / halted production release means published; a draft or nothing means first', () => {
expect(playReleaseKind([{ status: 'completed', versionCodes: ['41'] }]).kind).toBe('update');
expect(playReleaseKind([{ status: 'inProgress', versionCodes: ['42'] }, { status: 'completed', versionCodes: ['41'] }]).kind).toBe('update');
expect(playReleaseKind([{ status: 'halted', versionCodes: ['42'] }]).kind).toBe('update');
expect(playReleaseKind([{ status: 'draft', versionCodes: ['1'] }])).toMatchObject({ kind: 'first', why: expect.stringContaining('only draft') });
expect(playReleaseKind([]).kind).toBe('first');
expect(playReleaseKind(undefined).kind).toBe('first');
});
test('App Store: a version that is or was on sale means update; Prepare for Submission / in review / rejected means first', () => {
expect(appleReleaseKind([{ versionString: '1.0.0', state: 'READY_FOR_SALE' }, { versionString: '1.1.0', state: 'PREPARE_FOR_SUBMISSION' }]).kind).toBe('update');
expect(appleReleaseKind([{ state: 'REPLACED_WITH_NEW_VERSION' }]).kind).toBe('update');
expect(appleReleaseKind([{ state: 'READY_FOR_DISTRIBUTION' }]).kind).toBe('update');
expect(appleReleaseKind([{ state: 'PREPARE_FOR_SUBMISSION' }, { state: 'WAITING_FOR_REVIEW' }, { state: 'REJECTED' }]).kind).toBe('first');
expect(appleReleaseKind([]).kind).toBe('first');
});
test('the policy: updates always; a first release only with a prompt, the owner flag or bypass mode', () => {
const g = { yes: true, bypass: false, approved: false };
expect(firstReleaseAllowed({ ...g, kind: 'update' })).toBe(true);
expect(firstReleaseAllowed({ ...g, kind: 'first' })).toBe(false);
expect(firstReleaseAllowed({ ...g, kind: 'first', yes: false })).toBe(true); // the prompt is the owner's answer
expect(firstReleaseAllowed({ ...g, kind: 'first', approved: true })).toBe(true);
expect(firstReleaseAllowed({ ...g, kind: 'first', bypass: true })).toBe(true);
});
});
describe('Google Play production (fake store)', () => {
setDefaultTimeout(30_000);
let s: Stores;
beforeAll(() => { playEnv(); });
afterAll(restore);
beforeEach(() => { s = installStores(); });
afterEach(() => { uninstallStores(); delete process.env.STOREBOUND_HOME; });
const LISTING = '# Play\n\n## English\n<!-- play: en-US -->\n\n### Play short description\n```\nNotes.\n```\n\n### Play full description\n```\nNotes for everyone.\n```\n\n### What\'s new\n```\nFixes.\n```\n';
const published = (): void => { s.play.live.bundles = [41]; s.play.live.tracks = { production: { releases: [{ status: 'completed', versionCodes: ['41'] }] } }; };
const files = () => ({ 'store/listing.md': LISTING, 'build/android/app.aab': makeAab('com.example.notes', 42, '1.2.0') });
const upload = ['--json', '--yes', '--track', 'production'];
test('play upload --track production --yes on a published app: no owner gate, exit 0, exit 7 on the re-run', async () => {
published();
const p = project(BASE, files());
const first = await ship('play upload', upload, a => playUpload({ loaded: p.loaded, args: a }));
expect(first.err).not.toContain('error:');
expect(first.code).toBe(0);
expect(JSON.parse(first.out)).toMatchObject({ ok: true, step: 'upload', track: 'production', build: '42', state: 'completed' });
expect(s.play.committed.length).toBe(1);
const n = s.calls.length;
const again = await ship('play upload', upload, a => playUpload({ loaded: p.loaded, args: a }));
expect(again.code).toBe(7);
expect(writes(s.calls.slice(n))).toEqual([]);
});
test('play upload --track production --yes on a never-published app: exit 3 OWNER_STEP, nothing written', async () => {
const p = project(BASE, files());
const r = await ship('play upload', upload, a => playUpload({ loaded: p.loaded, args: a }));
expect(r.code).toBe(3);
const j = JSON.parse(r.out);
expect(j).toMatchObject({ ok: false, error: { code: 'OWNER_STEP' } });
expect(j.error.message).toContain('first release of this app');
expect(j.error.message).not.toContain('the owner sends it');
expect(j.error.consoleUrl).toContain('play.google.com/console');
expect(writes(s.calls)).toEqual([]);
expect(s.play.committed).toEqual([]);
});
test('the owner approved the first release (--first-release) or turned bypass mode on: the gate lets it through', async () => {
const p = project(BASE, files());
const ok = await ship('play upload', [...upload, '--first-release'], a => playUpload({ loaded: p.loaded, args: a }));
// Play takes only drafts on a never-published app: the gate passed, the store's own answer is the console step
expect(ok.err).toContain('first release (');
});
test('bypass mode (the owner opted in) lets a first release through the gate', async () => {
process.env.STOREBOUND_HOME = mkdtempSync(join(tmpdir(), 'sb-bypass-'));
setBypass(true);
const p2 = project(BASE, files());
const viaBypass = await ship('play upload', upload, a => playUpload({ loaded: p2.loaded, args: a }));
expect(viaBypass.code).not.toBe(3);
expect(viaBypass.err).toContain('bypass mode');
});
test('a plain run (no contract) of a first release is the usual declined exit 3', async () => {
const p = project(BASE, files());
const before = process.exitCode;
const err: string[] = [];
const ce = console.error;
console.error = (...a: unknown[]) => { err.push(a.map(String).join(' ')); };
try { await playUpload(p.ctx(['--yes', '--track', 'production'])); } finally { console.error = ce; }
expect(process.exitCode).toBe(3);
expect(err.join('\n')).toContain('declined: first release of this app');
process.exitCode = before;
expect(writes(s.calls)).toEqual([]);
});
test('play release <versionCode> --production completed --yes: an update goes through, then exit 7; a staged rollout is the same policy', async () => {
published();
s.play.live.bundles = [41, 42];
const p = project(BASE, { 'store/listing.md': LISTING });
const argv = ['--json', '--yes', '--version-code', '42', '--production', 'completed'];
const first = await ship('play release', argv, a => playRelease({ loaded: p.loaded, args: a }));
expect(first.err).not.toContain('error:');
expect(first.code).toBe(0);
expect(JSON.parse(first.out)).toMatchObject({ ok: true, step: 'release', track: 'production', build: '42', state: 'completed', releaseKind: 'update' });
const again = await ship('play release', argv, a => playRelease({ loaded: p.loaded, args: a }));
expect(again.code).toBe(7);
const staged = await ship('play release', ['--json', '--yes', '--version-code', '42', '--production', 'inProgress', '--fraction', '0.1'], a => playRelease({ loaded: p.loaded, args: a }));
expect(staged.code).toBe(0);
expect(JSON.parse(staged.out).tracks[0]).toMatchObject({ status: 'inProgress', userFraction: 0.1 });
});
test('play release --production completed --yes on a never-published app: exit 3 OWNER_STEP; --production draft is not gated', async () => {
s.play.live.bundles = [42];
const p = project(BASE, { 'store/listing.md': LISTING });
const refused = await ship('play release', ['--json', '--yes', '--version-code', '42', '--production', 'completed'], a => playRelease({ loaded: p.loaded, args: a }));
expect(refused.code).toBe(3);
expect(JSON.parse(refused.out)).toMatchObject({ ok: false, error: { code: 'OWNER_STEP' } });
expect(s.play.committed).toEqual([]);
const draft = await ship('play release', ['--json', '--yes', '--version-code', '42', '--production', 'draft'], a => playRelease({ loaded: p.loaded, args: a }));
expect(draft.code).toBe(0);
});
});
describe('App Store (fake store)', () => {
setDefaultTimeout(30_000);
let s: Stores;
beforeAll(() => { ascEnv(); });
afterAll(restore);
beforeEach(() => { s = installStores(); });
afterEach(() => { uninstallStores(); });
const version = (id: string, v: string, state: string): void => { s.asc.put({ type: 'appStoreVersions', id, attributes: { versionString: v, appVersionState: state, platform: 'IOS' }, rels: { app: APP } }); };
const build = (): void => { s.asc.put({ type: 'builds', id: 'b-8', attributes: { version: '8', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP } }); };
test('apple submit --yes for an update of an app on sale: no owner gate, exit 0; the re-run is exit 7', async () => {
version('ver-0', '1.0.0', 'READY_FOR_SALE');
version('ver-1', '1.1.0', 'PREPARE_FOR_SUBMISSION');
build();
const p = project(BASE);
const argv = ['--json', '--yes', '--build', '8'];
const r = await ship('apple submit', argv, a => appleSubmit({ loaded: p.loaded, args: a }));
expect(r.err).not.toContain('error:');
expect(r.code).toBe(0);
expect(JSON.parse(r.out)).toMatchObject({ ok: true, step: 'submit', store: 'apple', build: '8', releaseKind: 'update' });
expect(s.asc.all('reviewSubmissions').map(x => x.attributes.state)).toEqual(['WAITING_FOR_REVIEW']);
const n = s.calls.length;
const again = await ship('apple submit', argv, a => appleSubmit({ loaded: p.loaded, args: a }));
expect(again.code).toBe(7);
expect(writes(s.calls.slice(n))).toEqual([]);
});
test('apple submit --yes for the first version of an app: exit 3 OWNER_STEP, nothing written; --first-release sends it', async () => {
version('ver-1', '1.0.0', 'PREPARE_FOR_SUBMISSION');
build();
const p = project(BASE);
const r = await ship('apple submit', ['--json', '--yes', '--build', '8'], a => appleSubmit({ loaded: p.loaded, args: a }));
expect(r.code).toBe(3);
const j = JSON.parse(r.out);
expect(j).toMatchObject({ ok: false, error: { code: 'OWNER_STEP' } });
expect(j.error.message).toContain('first release of this app');
expect(writes(s.calls)).toEqual([]);
expect(s.asc.all('reviewSubmissions')).toEqual([]);
const ok = await ship('apple submit', ['--json', '--yes', '--build', '8', '--first-release'], a => appleSubmit({ loaded: p.loaded, args: a }));
expect(ok.code).toBe(0);
expect(s.asc.all('reviewSubmissions').map(x => x.attributes.state)).toEqual(['WAITING_FOR_REVIEW']);
});
test('apple submit without --yes is a dry run that names the kind (no gate, exit 0)', async () => {
version('ver-0', '1.0.0', 'READY_FOR_SALE');
version('ver-1', '1.1.0', 'PREPARE_FOR_SUBMISSION');
build();
const p = project(BASE);
const r = await ship('apple submit', ['--json', '--build', '8'], a => appleSubmit({ loaded: p.loaded, args: a }));
expect(r.code).toBe(0);
expect(JSON.parse(r.out)).toMatchObject({ state: 'planned', releaseKind: 'update' });
expect(writes(s.calls)).toEqual([]);
});
test('apple release --manual-release --yes: an approved update is released (exit 0), released already is exit 7', async () => {
version('ver-0', '1.0.0', 'REPLACED_WITH_NEW_VERSION');
version('ver-1', '1.1.0', 'PENDING_DEVELOPER_RELEASE');
const p = project(BASE);
const argv = ['--json', '--yes', '--manual-release', '--version', '1.1.0'];
const r = await ship('apple release', argv, a => appleRelease({ loaded: p.loaded, args: a }));
expect(r.err).not.toContain('error:');
expect(r.code).toBe(0);
expect(JSON.parse(r.out)).toMatchObject({ ok: true, step: 'release', store: 'apple', version: '1.1.0', releaseKind: 'update' });
expect(writes(s.calls).map(c => `${c.method} ${c.path}`)).toEqual(['POST /v1/appStoreVersionReleaseRequests']);
// the store moved it on: the same command again has nothing to release
s.asc.all('appStoreVersions').find(v => v.id === 'ver-1')!.attributes.appVersionState = 'READY_FOR_SALE';
const n = s.calls.length;
const again = await ship('apple release', argv, a => appleRelease({ loaded: p.loaded, args: a }));
expect(again.code).toBe(7);
expect(JSON.parse(again.out)).toMatchObject({ ok: true, state: 'READY_FOR_SALE' });
expect(writes(s.calls.slice(n))).toEqual([]);
});
test('apple release --manual-release --yes for the first version ever: exit 3 OWNER_STEP, no release request', async () => {
version('ver-1', '1.0.0', 'PENDING_DEVELOPER_RELEASE');
const p = project(BASE);
const r = await ship('apple release', ['--json', '--yes', '--manual-release'], a => appleRelease({ loaded: p.loaded, args: a }));
expect(r.code).toBe(3);
expect(JSON.parse(r.out)).toMatchObject({ ok: false, error: { code: 'OWNER_STEP' } });
expect(writes(s.calls)).toEqual([]);
});
});
describe('the policy is written down', () => {
const read = (f: string): string => readFileSync(join(import.meta.dir, '..', f), 'utf8');
test('docs, skills and the agent rules say updates are agent steps and the first release is the owner\'s', () => {
for (const f of ['docs/bypass.md', 'docs/ship-step.md', 'skills/release-management/SKILL.md', 'skills/play-first-release/SKILL.md', 'docs/agents-core.md']) {
const t = read(f);
expect(t, f).toContain('--first-release');
expect(t, f).toContain('OWNER_STEP');
}
expect(read('skills/store-pitfalls/SKILL.md')).toContain('Updates are agent steps');
expect(read('CHANGELOG.md')).toContain('OWNER_STEP');
});
});