From ae9d26d3f67d4f91483f6ee6ab11cef8772ccb80 Mon Sep 17 00:00:00 2001 From: CRT Date: Mon, 26 Oct 2026 18:09:50 +0700 Subject: [PATCH] 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 --- .cursor/rules/storebound.mdc | 6 + .github/copilot-instructions.md | 6 + AGENTS.md | 6 + CHANGELOG.md | 2 + GEMINI.md | 6 + cli/apple/release.ts | 38 ++++- cli/apple/resubmit.ts | 15 +- cli/commands.ts | 8 +- cli/data/flags.json | 20 ++- cli/lib/fastlane.ts | 2 +- cli/lib/release-kind.ts | 68 ++++++++ cli/lib/review-submit.ts | 2 +- cli/lib/ship-step.ts | 7 +- cli/mcp-tools.ts | 2 +- cli/play/release.ts | 25 ++- cli/play/upload.ts | 15 +- cli/serve.ts | 2 +- docs/agents-core.md | 6 + docs/bypass.md | 27 ++- docs/pitfalls.md | 1 + docs/ship-step.md | 29 +++- skills/play-first-release/SKILL.md | 4 +- skills/release-management/SKILL.md | 22 ++- skills/store-pitfalls/SKILL.md | 18 ++ tests/game-services.test.ts | 1 + tests/resubmit.test.ts | 7 +- tests/ship-step.test.ts | 2 + tests/update-gate.test.ts | 259 +++++++++++++++++++++++++++++ 28 files changed, 562 insertions(+), 44 deletions(-) create mode 100644 cli/lib/release-kind.ts create mode 100644 tests/update-gate.test.ts diff --git a/.cursor/rules/storebound.mdc b/.cursor/rules/storebound.mdc index 18a30b6..2b9d056 100644 --- a/.cursor/rules/storebound.mdc +++ b/.cursor/rules/storebound.mdc @@ -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 --production completed --yes`, `storebound play upload --track + production --yes`, `storebound apple submit --build --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 `, and console plans' `buttons.final`. Say in one line what you pressed; every action is logged to diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7ab0224..2082191 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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 --production completed --yes`, `storebound play upload --track + production --yes`, `storebound apple submit --build --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 `, and console plans' `buttons.final`. Say in one line what you pressed; every action is logged to diff --git a/AGENTS.md b/AGENTS.md index ea2ee8f..e1b9812 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 --production completed --yes`, `storebound play upload --track + production --yes`, `storebound apple submit --build --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 `, and console plans' `buttons.final`. Say in one line what you pressed; every action is logged to diff --git a/CHANGELOG.md b/CHANGELOG.md index 76a4214..54743a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unreleased +- Updates are agent steps (the owner's standing rule), first releases stay the owner's. `play release --production completed|inProgress --yes`, `play upload --track production --yes`, `apple submit --build --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. diff --git a/GEMINI.md b/GEMINI.md index 3677fc4..144e9e7 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -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 --production completed --yes`, `storebound play upload --track + production --yes`, `storebound apple submit --build --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 `, and console plans' `buttons.final`. Say in one line what you pressed; every action is logged to diff --git a/cli/apple/release.ts b/cli/apple/release.ts index 53c1680..ab0f77c 100644 --- a/cli/apple/release.ts +++ b/cli/apple/release.ts @@ -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 { 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 { 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; } diff --git a/cli/apple/resubmit.ts b/cli/apple/resubmit.ts index 9b23312..5bed343 100644 --- a/cli/apple/resubmit.ts +++ b/cli/apple/resubmit.ts @@ -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 [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--dry] [--yes] [--json] +// storebound apple resubmit --build [--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 ` (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') { diff --git a/cli/commands.ts b/cli/commands.ts index 949dd4e..90df327 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -59,9 +59,9 @@ export const COMMANDS: Record Promise, 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 , --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 Promise, 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 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 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 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 Promise, string]> = { 'apple status': [() => import('./apple/status'), 'versions, builds, IAPs, review submissions (--wait-review, --cancel-submission ); --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 [--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'], diff --git a/cli/data/flags.json b/cli/data/flags.json index 7a49ac1..798cab7 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -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 [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--dry] [--yes] [--json]" + "storebound apple resubmit --build [--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 ] [--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 [] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--pre-check \"\"] [--evidence ] [--dry] [--yes] [--json]" + "storebound play upload [] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--first-release] [--pre-check \"\"] [--evidence ] [--dry] [--yes] [--json]" ], "prices": [ "storebound prices export [--out store/prices.csv] [--store apple|play] [--only ids] [--with-automatic] [--no-subscriptions] [--dry] [--json]", diff --git a/cli/lib/fastlane.ts b/cli/lib/fastlane.ts index 36a04ab..1cc3ec8 100644 --- a/cli/lib/fastlane.ts +++ b/cli/lib/fastlane.ts @@ -617,7 +617,7 @@ ${secs.join('\n\n')} const ACT: Record = { 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 ` (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 ` (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`', diff --git a/cli/lib/release-kind.ts b/cli/lib/release-kind.ts new file mode 100644 index 0000000..1144500 --- /dev/null +++ b/cli/lib/release-kind.ts @@ -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; +} diff --git a/cli/lib/review-submit.ts b/cli/lib/review-submit.ts index d411190..d7b9585 100644 --- a/cli/lib/review-submit.ts +++ b/cli/lib/review-submit.ts @@ -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; } diff --git a/cli/lib/ship-step.ts b/cli/lib/ship-step.ts index af8e3da..8681609 100644 --- a/cli/lib/ship-step.ts +++ b/cli/lib/ship-step.ts @@ -29,10 +29,11 @@ export const SHIP_STEPS: Record = { '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 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 --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)] }, diff --git a/cli/play/release.ts b/cli/play/release.ts index 53fd29b..5e99e16 100644 --- a/cli/play/release.ts +++ b/cli/play/release.ts @@ -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 --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 { 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> | undefined; + const client = async (): Promise>> => (early ??= await playClient(root, pkg)); + let kind: ReturnType | 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 { 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 { } // 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'); } diff --git a/cli/play/upload.ts b/cli/play/upload.ts index 0ccad8d..ed4e200 100644 --- a/cli/play/upload.ts +++ b/cli/play/upload.ts @@ -1,12 +1,14 @@ -// Upload an Android App Bundle to a Google Play testing track. -// storebound play upload [] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--pre-check ""] [--evidence ] [--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 [] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--first-release] [--pre-check ""] [--evidence ] [--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 { 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; diff --git a/cli/serve.ts b/cli/serve.ts index 9b81220..f5f08c0 100644 --- a/cli/serve.ts +++ b/cli/serve.ts @@ -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)] }, diff --git a/docs/agents-core.md b/docs/agents-core.md index 0482818..2cb269a 100644 --- a/docs/agents-core.md +++ b/docs/agents-core.md @@ -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 --production completed --yes`, `storebound play upload --track + production --yes`, `storebound apple submit --build --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 `, and console plans' `buttons.final`. Say in one line what you pressed; every action is logged to diff --git a/docs/bypass.md b/docs/bypass.md index c82b6fc..ad50749 100644 --- a/docs/bypass.md +++ b/docs/bypass.md @@ -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 --production completed --yes` (or `--production inProgress --fraction 0.05`), or `storebound play upload --track production --yes` | +| App Store | `storebound apple submit --build --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`. diff --git a/docs/pitfalls.md b/docs/pitfalls.md index 55cc3a4..f3f01e7 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -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`). | diff --git a/docs/ship-step.md b/docs/ship-step.md index cb44b15..c26809f 100644 --- a/docs/ship-step.md +++ b/docs/ship-step.md @@ -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` diff --git a/skills/play-first-release/SKILL.md b/skills/play-first-release/SKILL.md index 393544a..894b677 100644 --- a/skills/play-first-release/SKILL.md +++ b/skills/play-first-release/SKILL.md @@ -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 diff --git a/skills/release-management/SKILL.md b/skills/release-management/SKILL.md index c5dded7..21bf298 100644 --- a/skills/release-management/SKILL.md +++ b/skills/release-management/SKILL.md @@ -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 --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 diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 7392b6d..48595dd 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -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 --production completed --yes` (or `--production inProgress --fraction 0.05`, or + `play upload --track production --yes`), `storebound apple submit --build --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". diff --git a/tests/game-services.test.ts b/tests/game-services.test.ts index 8554834..a365d6a 100644 --- a/tests/game-services.test.ts +++ b/tests/game-services.test.ts @@ -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 } }); }; diff --git a/tests/resubmit.test.ts b/tests/resubmit.test.ts index ac9259f..54fe538 100644 --- a/tests/resubmit.test.ts +++ b/tests/resubmit.test.ts @@ -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 ', () => { describe('apple submit --build (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 (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']))); diff --git a/tests/ship-step.test.ts b/tests/ship-step.test.ts index 133b817..851da8c 100644 --- a/tests/ship-step.test.ts +++ b/tests/ship-step.test.ts @@ -235,6 +235,8 @@ describe('idempotent re-upload (fake stores)', () => { const LISTING = '# Play\n\n## English\n\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); diff --git a/tests/update-gate.test.ts b/tests/update-gate.test.ts new file mode 100644 index 0000000..0d9e237 --- /dev/null +++ b/tests/update-gate.test.ts @@ -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) => Promise): 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\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 --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'); + }); +});