Updates are agent steps: --yes ships an update of a live app (Play production, App Store submit / release) without an owner gate; the first release stays the owner's (exit 3 OWNER_STEP, --first-release); apple release is a ship step
This commit is contained in:
parent
c247b7e54a
commit
ae9d26d3f6
28 changed files with 562 additions and 44 deletions
|
|
@ -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
|
||||
|
|
|
|||
6
.github/copilot-instructions.md
vendored
6
.github/copilot-instructions.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
// Release management for an App Store version.
|
||||
// storebound apple release --phased on|pause|resume|complete [--version 1.2.0] [--dry] [--yes]
|
||||
// storebound apple release --manual-release [--version 1.2.0] [--yes]
|
||||
// storebound apple release --manual-release [--version 1.2.0] [--first-release] [--yes] [--json]
|
||||
// Both take --platform ios|macos|tvos|visionos; without it the platform that has the version this needs is used (both: asks).
|
||||
// Phased release: 7-day automatic rollout to users with automatic updates (1, 2, 5, 10, 20, 50, 100 %); anyone can
|
||||
// still update by hand from the App Store. Pause holds it (up to 30 days in total), it is NOT a rollback: users who
|
||||
// updated keep the build. "complete" releases to everyone at once.
|
||||
// --manual-release releases an approved version (PENDING_DEVELOPER_RELEASE). Public and irreversible: asks first.
|
||||
// Neither --manual-release nor resume / complete is offered to agents through `storebound mcp`.
|
||||
// --manual-release releases an approved version (PENDING_DEVELOPER_RELEASE). Public and irreversible: asks first
|
||||
// (--yes in scripts). Releasing an UPDATE of an app already on sale is an agent step (no owner gate, also in a
|
||||
// pipeline; under the ship-step contract: exit 7 when the version is released already). The first-ever release of an
|
||||
// app with --yes is exit 3 OWNER_STEP unless the owner approved it (--first-release, or bypass mode;
|
||||
// cli/lib/release-kind.ts). Neither --manual-release nor resume / complete is offered to agents through `storebound mcp`.
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { confirm, need } from '../lib/ctx';
|
||||
import { done, log } from '../lib/log';
|
||||
import { done, log, printJson } from '../lib/log';
|
||||
import { shipNoop, shipReport, shipRun } from '../lib/ship-step';
|
||||
import { consoleUrl } from '../lib/console-links';
|
||||
import { appleReleaseKind, enforceFirstRelease } from '../lib/release-kind';
|
||||
import { type PhasedAction, phasedRequest } from '../lib/release';
|
||||
import { createOnce } from '../lib/upsert';
|
||||
import { ascClient, type J } from './asc';
|
||||
|
|
@ -27,6 +33,19 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const appId = await c.app();
|
||||
const want = a.get('version');
|
||||
const state = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState;
|
||||
shipReport({ version: want, url: consoleUrl('asc.version') });
|
||||
// the ship-step contract: the version is released already (and none waits for release): nothing to do, exit 7
|
||||
if (manual && shipRun()) {
|
||||
const every = await allVersions(c, appId);
|
||||
const waiting = every.some((x: J) => state(x) === 'PENDING_DEVELOPER_RELEASE' && (!want || x.attributes.versionString === want));
|
||||
const out = every.find((x: J) => ['READY_FOR_SALE', 'READY_FOR_DISTRIBUTION', 'PROCESSING_FOR_DISTRIBUTION'].includes(state(x)) && (!want || x.attributes.versionString === want));
|
||||
if (!waiting && out) {
|
||||
shipNoop({ version: String(out.attributes.versionString), state: state(out) });
|
||||
if (a.has('json')) printJson({ version: String(out.attributes.versionString), state: state(out), released: true, alreadyThere: true });
|
||||
done('apple', `${String(out.attributes.versionString)} is ${state(out)} already: nothing released`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// the platform: --platform, else the one with the version this needs (both: asks)
|
||||
const needs = (x: J): boolean => (!want || x.attributes.versionString === want) && (manual ? state(x) === 'PENDING_DEVELOPER_RELEASE'
|
||||
: phased === 'on' ? !['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE', 'REPLACED_WITH_NEW_VERSION', 'REMOVED_FROM_SALE'].includes(state(x))
|
||||
|
|
@ -44,10 +63,19 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (state(v) !== 'PENDING_DEVELOPER_RELEASE') throw new Error(`${tag}: only an approved version waiting for developer release can be released`);
|
||||
const pr = (await c.maybe(`/v1/appStoreVersions/${v.id}/appStoreVersionPhasedRelease`))?.data;
|
||||
const how = pr ? 'phased over 7 days (phased release is on)' : 'to everyone at once (no phased release)';
|
||||
if (dry) { log('apple', `would release ${tag} ${how} (dry)`); return; }
|
||||
const kind = appleReleaseKind(versions.map((x: J) => ({ versionString: String(x.attributes.versionString), state: state(x) })));
|
||||
shipReport({ version: String(v.attributes.versionString), state: state(v) });
|
||||
if (dry) {
|
||||
log('apple', `would release ${tag} ${how} (dry): ${kind.kind === 'update' ? 'update of an app on sale, no owner step' : 'FIRST release of this app (the owner approves it once: --first-release)'} (${kind.why})`);
|
||||
if (a.has('json')) printJson({ version: String(v.attributes.versionString), state: state(v), releaseKind: kind.kind, dryRun: true });
|
||||
return;
|
||||
}
|
||||
if (!enforceFirstRelease(ctx, kind, { store: 'apple', what: 'the first release of the app', consoleUrl: consoleUrl('asc.version') })) return;
|
||||
if (!(await confirm(ctx, `Release ${tag} on the App Store now, ${how}? This is public.`))) return;
|
||||
await c.asc('POST', '/v1/appStoreVersionReleaseRequests', { data: { type: 'appStoreVersionReleaseRequests',
|
||||
relationships: { appStoreVersion: { data: { type: 'appStoreVersions', id: v.id } } } } });
|
||||
shipReport({ state: 'PENDING_APPLE_RELEASE' });
|
||||
if (a.has('json')) printJson({ version: String(v.attributes.versionString), state: 'PENDING_APPLE_RELEASE', releaseKind: kind.kind, released: true });
|
||||
done('apple', `release requested for ${tag}; the store updates within a few hours`);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// Resubmit an App Store version after a rejection (Invalid Binary, Guideline 2.1, ...) with a new build.
|
||||
// storebound apple resubmit --build <n> [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--dry] [--yes] [--json]
|
||||
// storebound apple resubmit --build <n> [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--first-release] [--dry] [--yes] [--json]
|
||||
// The platform: --platform, else the one with a rejected version or an UNRESOLVED_ISSUES submission (both: asks).
|
||||
// 1. The rejected version (--version, else the one in the submission back from App Review, else the one in a
|
||||
// rejected state) gets build n attached, by explicit number (never "the latest"); it must be VALID.
|
||||
|
|
@ -13,7 +13,10 @@
|
|||
// 4. PATCH submitted: true. On "not ready yet" the items are read again: a REJECTED one left over stops the run with
|
||||
// the item and how to resolve it (retrying never passes); only when none is left is the submit retried with backoff
|
||||
// (right after a build is attached), with heartbeat lines and a 10-minute limit.
|
||||
// Submitting is the owner's step: without --yes this is a dry run with the plan table. Not offered through MCP.
|
||||
// Without --yes this is a dry run with the plan table. Submitting an UPDATE of an app that is already on sale (a version
|
||||
// of the platform is or was READY_FOR_SALE) is an agent step: --yes sends it, no owner gate, also in a pipeline. The
|
||||
// first-ever submission of an app is the owner's: with --yes it is exit 3 OWNER_STEP unless the owner approved it
|
||||
// (--first-release, or bypass mode); cli/lib/release-kind.ts. Not offered through MCP.
|
||||
// `storebound apple submit --build <n>` (cli/apple/submit.ts) runs the same steps for a first submission: the version
|
||||
// is --version, else the one an open submission holds, else the editable one (Prepare for Submission or rejected).
|
||||
// Under `serve` (the desktop's owner action) the plan table is printed next to the --json result.
|
||||
|
|
@ -30,6 +33,7 @@ import { openSubmissions } from './submission';
|
|||
import { ensureGcSwitch, gameCenterInUse, gcSwitchPlan, readGcSwitch } from './game-center';
|
||||
import { shipNoop, shipPhase, shipReport, shipRun } from '../lib/ship-step';
|
||||
import { consoleUrl } from '../lib/console-links';
|
||||
import { appleReleaseKind, enforceFirstRelease } from '../lib/release-kind';
|
||||
import { allVersions, parsePlatform, pickPlatform, platformLabel, platformOf, versionsPath } from './platform';
|
||||
|
||||
/** App Store version states a first submission may send: the editable version (Prepare for Submission, a rejected
|
||||
|
|
@ -112,13 +116,14 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
|
|||
const marks = state.choice?.action === 'reuse' ? toResolve(state.choice.sub.items, resolve).map(i => ({ item: i.id ?? null, kind: i.kind, what: labelOf(state, i.versionId, i.kind) })) : [];
|
||||
const blockers = [...(build.action === 'refuse' ? [build.reason] : []), ...plan.blockers];
|
||||
const dry = a.has('dry') || !a.has('yes');
|
||||
const kind = appleReleaseKind(versions.map((x: J) => ({ versionString: String(x.attributes.versionString), state: stateOf(x) })));
|
||||
// Game Center: a build with the entitlement needs the app's Game Center detail and the version's switch, else the
|
||||
// submission answers 409 STATE_ERROR.BUILD_INDICATES_GAME_CENTER_ENABLED (seen live); the same upsert as apple game-center
|
||||
const gcUse = gameCenterInUse(config, root);
|
||||
const gc = gcUse.inUse ? await readGcSwitch(c, appId, v) : null;
|
||||
const gcPlan = gc ? gcSwitchPlan(gc) : [];
|
||||
if (a.has('json')) {
|
||||
printJson({ dryRun: dry, gameCenter: gcPlan, version: { id: v.id, versionString, platform, state: stateOf(v) }, build, submission: state.choice ?? null, resolve: marks, rows, skipped: plan.skipped, blockers });
|
||||
printJson({ dryRun: dry, releaseKind: kind.kind, gameCenter: gcPlan, version: { id: v.id, versionString, platform, state: stateOf(v) }, build, submission: state.choice ?? null, resolve: marks, rows, skipped: plan.skipped, blockers });
|
||||
// the desktop's owner dialog shows the output text: the plan table goes with the result there
|
||||
if (process.env.STOREBOUND_MACHINE === '1') { console.log(formatRows(rows)); for (const x of blockers) warn(TAG, x); }
|
||||
if (blockers.length) process.exitCode = 1;
|
||||
|
|
@ -126,6 +131,7 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
|
|||
if (dry || blockers.length || state.choice?.action === 'busy') return;
|
||||
} else {
|
||||
console.log(formatRows(rows));
|
||||
log(TAG, `${kind.kind === 'update' ? 'update of an app on sale' : 'FIRST release of this app (the owner approves it once: --first-release)'}: ${kind.why}`);
|
||||
for (const l of gcPlan) log(TAG, `${dry ? 'would ' : ''}${l} (Game Center: ${gcUse.why})`);
|
||||
for (const s of plan.skipped) log(TAG, `not added ${s.id}: ${s.why}`);
|
||||
for (const x of blockers) warn(TAG, x);
|
||||
|
|
@ -133,9 +139,10 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
|
|||
if (blockers.length) { process.exitCode = 1; done(TAG, 'nothing sent: fix the blockers above'); return; }
|
||||
if (state.choice?.action === 'busy') { shipNoop({ version: versionString, state: stateOf(v) }); done(TAG, `nothing to ${mode}: ${state.choice.why}`); return; }
|
||||
if (dry) {
|
||||
done(TAG, a.has('dry') ? 'dry run: nothing sent' : `dry run (no --yes): nothing sent. ${mode === 'submit' ? 'Submitting' : 'Resubmitting'} is the owner's step: after the owner's yes, run it again with --yes`);
|
||||
done(TAG, a.has('dry') ? 'dry run: nothing sent' : `dry run (no --yes): nothing sent. ${kind.kind === 'update' ? 'This is an update of an app on sale: run it again with --yes, no owner step' : `${mode === 'submit' ? 'Submitting' : 'Resubmitting'} the first release is the owner's step: after the owner's yes, run it again with --yes --first-release`}`);
|
||||
return;
|
||||
}
|
||||
if (!enforceFirstRelease(ctx, kind, { store: 'apple', what: 'the first submission to App Review', consoleUrl: consoleUrl('asc.version') })) return;
|
||||
|
||||
shipPhase('submit', `${mode === 'submit' ? 'submitting' : 'resubmitting'} ${versionString} with build ${number}`);
|
||||
if (build.action === 'attach') {
|
||||
|
|
|
|||
|
|
@ -59,9 +59,9 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
|||
'play listing': [() => import('./play/listing'), 'app details + title / short / full description per language; pull [--write]: live listings back into the listing file (diff first)'],
|
||||
'play images': [() => import('./play/images'), 'icon, feature graphic, phone screenshots per language, optional tablet / TV / Wear sets when rendered (play.tablets); --variant <id>, --prune'],
|
||||
'play data-safety': [() => import('./play/data-safety'), '[--force] Data safety form (CSV import through the API; skipped when unchanged since the last upload)'],
|
||||
'play release': [() => import('./play/release'), 'upload an AAB and put it on tracks with release notes'],
|
||||
'play release': [() => import('./play/release'), 'upload an AAB and put it on tracks with release notes; --production completed|inProgress --yes ships an UPDATE of a published app without an owner step (a first-ever production release is the owner\'s: exit 3 OWNER_STEP, --first-release after the owner\'s yes)'],
|
||||
'play build': [() => import('./play/build'), 'gradle bundleRelease (Flutter: flutter build appbundle) with the next free versionCode and the upload key from env / .storebound; --dry'],
|
||||
'play upload': [() => import('./play/upload'), 'upload an AAB to a testing track (--track internal|alpha|beta), versionCode checked first; --dry read-only, asks first'],
|
||||
'play upload': [() => import('./play/upload'), 'upload an AAB to a track (--track internal|alpha|beta|production), versionCode checked first; production with --yes is an update step for a published app (a first-ever release is the owner\'s: exit 3 OWNER_STEP); --dry read-only, asks first'],
|
||||
'play status': [() => import('./play/status'), 'tracks, listings, products (read-only)'],
|
||||
'play rollout': [() => import('./play/rollout'), 'staged rollout: --fraction x | --halt | --resume | --complete (asks first)'],
|
||||
'play share': [() => import('./play/share'), 'internal app sharing: upload an AAB / APK, print the share link (asks first; --dry)'],
|
||||
|
|
@ -74,7 +74,7 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
|||
'apple subscriptions': [() => import('./apple/subscriptions'), 'subscription groups, subscriptions, localizations, prices (equalized + overrides + --csv), intro offers, availability, review screenshot (asks first)'],
|
||||
'apple iap-review': [() => import('./apple/iap-review'), 'IAP review screenshots + product states'],
|
||||
'apple iap-submit': [() => import('./apple/iap-submit'), 'IAP + subscription versions into a review submission (first of a type: --with-version), then submit (asks first; --no-submit keeps a draft); reuses the open submission, never a second one'],
|
||||
'apple submit': [() => import('./apple/submit'), 'a version to App Review: --build <n> attached (by number, VALID), the open submission reused, the ready IAPs / subscriptions with it, submitted with retry (dry run unless --yes); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
|
||||
'apple submit': [() => import('./apple/submit'), 'a version to App Review: --build <n> attached (by number, VALID), the open submission reused, the ready IAPs / subscriptions with it, submitted with retry (dry run unless --yes; an UPDATE of an app on sale needs no owner step, the first-ever submission is exit 3 OWNER_STEP unless --first-release); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
|
||||
'apple resubmit': [() => import('./apple/resubmit'), 'after a rejection: --build <n> attached to the rejected version, the UNRESOLVED_ISSUES submission reused (missing items added), resubmitted with retry (dry run unless --yes); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
|
||||
'apple offer-codes': [() => import('./apple/offer-codes'), 'offer codes for one-time purchases (campaigns; custom and one-time-use codes with --codes, asks first)'],
|
||||
'apple win-back': [() => import('./apple/win-back'), 'win-back offers for lapsed subscribers (asks first: they go live on their start date)'],
|
||||
|
|
@ -89,7 +89,7 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
|||
'apple status': [() => import('./apple/status'), 'versions, builds, IAPs, review submissions (--wait-review, --cancel-submission <id>); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
|
||||
'apple creative-assets': [() => import('./apple/creative-assets'), 'Header and Search Results: product page header (21:9) and search results asset (3:2) per locale, or one universal 5244x2950 PNG; uploads to the Asset Library and places it once Apple approved it, reporting waiting ones per locale (MD5 upsert, safe to re-run); --add-for-review, --verify (read-only), --dry'],
|
||||
'apple previews': [() => import('./apple/previews'), 'app preview videos per locale (MD5-idempotent, poster frame time code); --dry'],
|
||||
'apple release': [() => import('./apple/release'), 'phased release on|pause|resume|complete, --manual-release (asks first); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
|
||||
'apple release': [() => import('./apple/release'), 'phased release on|pause|resume|complete, --manual-release (asks first; --yes for an update of an app on sale, exit 7 when already released; the first-ever release is exit 3 OWNER_STEP unless --first-release); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
|
||||
'apple signing': [() => import('./apple/signing'), 'status | ensure: bundle id capabilities, distribution certificate (key kept in .storebound/signing), App Store profiles installed for Xcode; --dry, asks first'],
|
||||
'apple build': [() => import('./apple/build'), 'xcodebuild archive + exportArchive (ExportOptions.plist generated, next build number, API key signing); Capacitor, React Native, Flutter, Unity; --platform macos --tauri <src-tauri> [--path-free] builds a Tauri app for the Mac App Store (signed .app, sandbox entitlements, profile, productbuild .pkg); --dry'],
|
||||
'apple upload': [() => import('./apple/upload'), 'upload an IPA, or with --platform macos a signed .pkg (App Store Connect API build upload, or --via altool), build number checked, waits for processing; --dry, asks first'],
|
||||
|
|
|
|||
|
|
@ -228,14 +228,20 @@
|
|||
"enable"
|
||||
],
|
||||
"apple release": [
|
||||
"env-file",
|
||||
"first-release",
|
||||
"manual-release",
|
||||
"phased",
|
||||
"platform",
|
||||
"version"
|
||||
"progress",
|
||||
"result-file",
|
||||
"version",
|
||||
"version-from"
|
||||
],
|
||||
"apple resubmit": [
|
||||
"build",
|
||||
"env-file",
|
||||
"first-release",
|
||||
"keychain",
|
||||
"locale-map",
|
||||
"new-version",
|
||||
|
|
@ -284,6 +290,7 @@
|
|||
"apple submit": [
|
||||
"build",
|
||||
"env-file",
|
||||
"first-release",
|
||||
"keychain",
|
||||
"locale-map",
|
||||
"new-version",
|
||||
|
|
@ -758,6 +765,7 @@
|
|||
"alpha",
|
||||
"beta",
|
||||
"env-file",
|
||||
"first-release",
|
||||
"fraction",
|
||||
"include-drafts",
|
||||
"internal",
|
||||
|
|
@ -821,6 +829,7 @@
|
|||
"play upload": [
|
||||
"env-file",
|
||||
"evidence",
|
||||
"first-release",
|
||||
"include-drafts",
|
||||
"name",
|
||||
"not-sent-for-review",
|
||||
|
|
@ -1205,6 +1214,7 @@
|
|||
"expo",
|
||||
"fields",
|
||||
"file",
|
||||
"first-release",
|
||||
"flavor",
|
||||
"floor",
|
||||
"flutter",
|
||||
|
|
@ -1514,10 +1524,10 @@
|
|||
],
|
||||
"apple release": [
|
||||
"storebound apple release --phased on|pause|resume|complete [--version 1.2.0] [--dry] [--yes]",
|
||||
"storebound apple release --manual-release [--version 1.2.0] [--yes]"
|
||||
"storebound apple release --manual-release [--version 1.2.0] [--first-release] [--yes] [--json]"
|
||||
],
|
||||
"apple resubmit": [
|
||||
"storebound apple resubmit --build <n> [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--dry] [--yes] [--json]"
|
||||
"storebound apple resubmit --build <n> [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--first-release] [--dry] [--yes] [--json]"
|
||||
],
|
||||
"apple review-details": [
|
||||
"storebound apple review-details [--contact-from <Apple ID of another app>] [--dry]"
|
||||
|
|
@ -1688,7 +1698,7 @@
|
|||
"storebound play products [--dry] [--only id1,id2] [--yes] [--allow-price-changes]"
|
||||
],
|
||||
"play release": [
|
||||
"storebound play release app-release.aab [--internal completed] [--production draft] [--name \"1.0.0 (3)\"] [--dry]",
|
||||
"storebound play release app-release.aab [--internal completed] [--production draft] [--name \"1.0.0 (3)\"] [--first-release] [--dry]",
|
||||
"storebound play release --version-code 3 --production draft",
|
||||
"storebound play release app.aab --production completed --not-sent-for-review (commit with changesNotSentForReview=true)"
|
||||
],
|
||||
|
|
@ -1705,7 +1715,7 @@
|
|||
"storebound play subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--yes] [--allow-price-changes]"
|
||||
],
|
||||
"play upload": [
|
||||
"storebound play upload [<app.aab>] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--pre-check \"<cmd with {file}>\"] [--evidence <path.json>] [--dry] [--yes] [--json]"
|
||||
"storebound play upload [<app.aab>] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--first-release] [--pre-check \"<cmd with {file}>\"] [--evidence <path.json>] [--dry] [--yes] [--json]"
|
||||
],
|
||||
"prices": [
|
||||
"storebound prices export [--out store/prices.csv] [--store apple|play] [--only ids] [--with-automatic] [--no-subscriptions] [--dry] [--json]",
|
||||
|
|
|
|||
|
|
@ -617,7 +617,7 @@ ${secs.join('\n\n')}
|
|||
const ACT: Record<string, string> = {
|
||||
app_store_connect_api_key: 'ASC_KEY_ID / ASC_ISSUER_ID / ASC_P8 in .storebound/credentials.json (the .p8 path, never its contents)',
|
||||
upload_to_app_store: '`storebound apple listing`, `apple screenshots`, `apple previews` (dry run first; submitting stays with the owner)', deliver: '`storebound apple listing` / `apple screenshots`',
|
||||
upload_to_play_store: '`storebound play listing`, `play images`, `play release <aab>` (testing tracks; production is the owner\'s)', supply: '`storebound play listing` / `play images` / `play release`',
|
||||
upload_to_play_store: '`storebound play listing`, `play images`, `play release <aab>` (testing tracks; production updates of a published app: --production completed --yes; the first-ever release is the owner\'s)', supply: '`storebound play listing` / `play images` / `play release`',
|
||||
upload_to_testflight: '`storebound apple testflight` (testflight skill)', pilot: '`storebound apple testflight` (testflight skill)',
|
||||
match: 'keep match for signing', sync_code_signing: 'keep match for signing',
|
||||
snapshot: '`storebound shots capture --source ios-sim`', capture_screenshots: '`storebound shots capture --source ios-sim`', capture_ios_screenshots: '`storebound shots capture --source ios-sim`',
|
||||
|
|
|
|||
68
cli/lib/release-kind.ts
Normal file
68
cli/lib/release-kind.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// Update or first release? The owner's standing rule (docs/bypass.md, "Updates are agent steps"): a new version of an
|
||||
// app that is ALREADY on the store ships end to end without an owner gate (submit for review, promote to production,
|
||||
// roll out, release an approved version) with --yes, also in a pipeline. The gate stays for the first-ever release of an
|
||||
// app (and, in their own commands, removal from sale and live price changes).
|
||||
// The kind comes from the store, never from the config:
|
||||
// Google Play the production track holds a completed / inProgress / halted release (the app was published)
|
||||
// App Store the platform has a version that is or was on sale (READY_FOR_SALE, READY_FOR_DISTRIBUTION,
|
||||
// REPLACED_WITH_NEW_VERSION)
|
||||
// A first release with --yes (nobody to answer a prompt) is refused with exit 3 `OWNER_STEP` unless the owner approved
|
||||
// it: `--first-release` after the owner's yes in chat, or bypass mode (docs/bypass.md). In a terminal without --yes the
|
||||
// normal confirmation prompt is the owner's approval.
|
||||
import { bypassOn } from './bypass';
|
||||
import type { Ctx } from './ctx';
|
||||
import { declined } from './ctx';
|
||||
import { log } from './log';
|
||||
import { SHIP_EXIT, ShipStepError, shipRun } from './ship-step';
|
||||
|
||||
export type ReleaseKind = 'update' | 'first';
|
||||
export interface KindAnswer { kind: ReleaseKind; why: string }
|
||||
|
||||
/** Play: the production track's releases say whether the app was ever published. Pure. */
|
||||
export function playReleaseKind(production: { status?: string; versionCodes?: string[] }[] | undefined): KindAnswer {
|
||||
const live = (production ?? []).filter(r => ['completed', 'inProgress', 'halted'].includes(String(r.status)));
|
||||
if (live.length) return { kind: 'update', why: `the production track holds ${live.map(r => `${r.status} ${(r.versionCodes ?? []).join(',')}`).join('; ')}` };
|
||||
const drafts = (production ?? []).length;
|
||||
return { kind: 'first', why: drafts ? 'the production track has only draft releases: the app was never published' : 'the production track is empty: the app was never published' };
|
||||
}
|
||||
|
||||
/** App Store version states that prove the app was on sale. */
|
||||
export const APPLE_LIVE_STATES = ['READY_FOR_SALE', 'READY_FOR_DISTRIBUTION', 'REPLACED_WITH_NEW_VERSION'] as const;
|
||||
|
||||
/** App Store: the versions of one platform say whether the app was ever on sale. Pure. */
|
||||
export function appleReleaseKind(versions: { versionString?: string; state: string }[]): KindAnswer {
|
||||
const live = versions.filter(v => (APPLE_LIVE_STATES as readonly string[]).includes(v.state));
|
||||
if (live.length) return { kind: 'update', why: `${live.map(v => `${v.versionString ?? '?'} ${v.state}`).join(', ')} on this platform` };
|
||||
return { kind: 'first', why: 'no version of this app was ever on sale on this platform' };
|
||||
}
|
||||
|
||||
export interface GateInput { kind: ReleaseKind; yes: boolean; bypass: boolean; approved: boolean }
|
||||
/** Whether the step may run. Updates always; a first release only with a person answering a prompt (no --yes), the
|
||||
* owner's `--first-release`, or bypass mode. Pure. */
|
||||
export function firstReleaseAllowed(g: GateInput): boolean {
|
||||
return g.kind === 'update' || !g.yes || g.approved || g.bypass;
|
||||
}
|
||||
|
||||
export const firstReleaseMessage = (store: 'play' | 'apple', what: string): string =>
|
||||
`first release of this app (${store === 'play' ? 'Google Play' : 'App Store'}): ${what} is the owner's step, approved once. Updates of an app that is already live need no approval. `
|
||||
+ 'After the owner\'s yes: re-run with --first-release (or turn on bypass mode: storebound settings bypass on)';
|
||||
|
||||
/**
|
||||
* Applies the policy to a real run. Returns true when the step may go on. A refusal is exit 3: error.code OWNER_STEP
|
||||
* under the ship-step contract (with the console deep link), the usual "declined" exit 3 otherwise.
|
||||
*/
|
||||
export function enforceFirstRelease(ctx: Ctx, k: KindAnswer, o: { store: 'play' | 'apple'; what: string; consoleUrl?: string }): boolean {
|
||||
const a = ctx.args;
|
||||
const bypass = bypassOn();
|
||||
// the desktop's owner action: the owner pressed the button of a native dialog that showed the dry run (cli/lib/ctx.ts confirm)
|
||||
const approved = a.has('first-release') || (process.env.STOREBOUND_SERVE === '1' && process.env.STOREBOUND_OWNER_CONFIRMED === '1');
|
||||
if (firstReleaseAllowed({ kind: k.kind, yes: a.has('yes'), bypass, approved })) {
|
||||
if (k.kind === 'update') log('release', `update of a live app (${k.why}): no owner step needed`);
|
||||
else log('release', `first release (${k.why}): ${approved ? 'approved by the owner' : bypass ? 'bypass mode' : 'confirmation prompt'}`);
|
||||
return true;
|
||||
}
|
||||
const msg = `${firstReleaseMessage(o.store, o.what)} (${k.why})`;
|
||||
if (shipRun()) throw new ShipStepError(msg, SHIP_EXIT.owner, 'OWNER_STEP', o.consoleUrl);
|
||||
declined(msg);
|
||||
return false;
|
||||
}
|
||||
|
|
@ -286,7 +286,7 @@ export function resubmitRows(s: SubmitState, plan: SubmitPlan, build: BuildStep,
|
|||
}
|
||||
for (const it of plan.items) rows.push({ step: 'item', what: it.label, now: it.devRejected ? 'DEVELOPER_REJECTED' : '-', action: addAction(it) });
|
||||
if (ch?.action === 'reuse') for (const d of ch.strayDrafts) rows.push({ step: 'stray draft', what: d.id, now: `${d.state}, ${d.items.length} item(s)`, action: d.items.length ? 'left as it is (named)' : `left as it is: ${EMPTY_DRAFT}` });
|
||||
if (ch?.action !== 'busy') rows.push({ step: 'submit', what: 'PATCH submitted: true', now: '-', action: 'owner step (--yes); retried while Apple says "not ready yet" and no item is left REJECTED' });
|
||||
if (ch?.action !== 'busy') rows.push({ step: 'submit', what: 'PATCH submitted: true', now: '-', action: 'send with --yes (update of an app on sale: no owner step; the first-ever release: the owner approves it once); retried while Apple says "not ready yet" and no item is left REJECTED' });
|
||||
return rows;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,10 +29,11 @@ export const SHIP_STEPS: Record<string, { step: string; store: 'apple' | 'play'
|
|||
'apple upload': { step: 'upload', store: 'apple' },
|
||||
'apple submit': { step: 'submit', store: 'apple' },
|
||||
'apple resubmit': { step: 'resubmit', store: 'apple' },
|
||||
'apple release': { step: 'release', store: 'apple' },
|
||||
};
|
||||
|
||||
/** error.code of an exit 3: what the owner has to do. */
|
||||
export const OWNER_CODES = ['AGREEMENT_PENDING', 'SIGN_IN_REQUIRED', 'COMPLIANCE_ANSWER_MISSING', 'CONSOLE_STEP', 'CONFIRMATION_REQUIRED'] as const;
|
||||
export const OWNER_CODES = ['AGREEMENT_PENDING', 'SIGN_IN_REQUIRED', 'COMPLIANCE_ANSWER_MISSING', 'CONSOLE_STEP', 'CONFIRMATION_REQUIRED', 'OWNER_STEP'] as const;
|
||||
|
||||
export interface ShipErrorInfo { code: string; message: string; consoleUrl?: string }
|
||||
export interface ShipResult {
|
||||
|
|
@ -155,13 +156,14 @@ export function fieldsFromDetail(key: string, d: unknown): Fields {
|
|||
const b = x.build as { number?: string } | undefined;
|
||||
return { version: str(v?.versionString), build: str(b?.number), state: dry ? 'planned' : 'WAITING_FOR_REVIEW' };
|
||||
}
|
||||
case 'apple release': return { version: str(x.version), state: dry ? 'planned' : str(x.state) };
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_URL: Record<string, ConsolePage> = {
|
||||
'play build': 'play.bundle-explorer', 'play upload': 'play.releases', 'play release': 'play.releases',
|
||||
'apple build': 'asc.testflight', 'apple upload': 'asc.testflight', 'apple submit': 'asc.app-review', 'apple resubmit': 'asc.app-review',
|
||||
'apple build': 'asc.testflight', 'apple upload': 'asc.testflight', 'apple submit': 'asc.app-review', 'apple resubmit': 'asc.app-review', 'apple release': 'asc.version',
|
||||
};
|
||||
|
||||
/** The final result: the contract keys, then the command's own --json fields that do not clash with them (so readers
|
||||
|
|
@ -297,6 +299,7 @@ const VERSION_FLAGS: Record<string, { version?: string; build?: string; intBuild
|
|||
'apple upload': { build: 'build' },
|
||||
'apple submit': { version: 'version', build: 'build' },
|
||||
'apple resubmit': { version: 'version', build: 'build' },
|
||||
'apple release': { version: 'version' },
|
||||
};
|
||||
|
||||
/** The argv with the flags of --version-from added (a flag given by hand must agree with the file). */
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ export const TOOLS: ToolDef[] = [
|
|||
description: 'Replace the whole Play Data safety form with the answers from the config (CSV import). Skipped when the CSV equals the last upload of this project (hash in .storebound/; force: true sends it anyway). dry_run prints the CSV.',
|
||||
props: { force: { type: 'boolean', default: false, description: 'Upload even when the CSV equals the last upload (after a manual change in Play Console)' } }, args: i => flag('force', i.force) },
|
||||
{ name: 'play_release_testing', title: 'Release to a Play testing track', command: ['play', 'release'], write: true, network: true,
|
||||
description: 'Upload an AAB (or reuse a versionCode) to a TESTING track (internal, alpha, beta) with release notes. Production is not available here.',
|
||||
description: 'Upload an AAB (or reuse a versionCode) to a TESTING track (internal, alpha, beta) with release notes. Production is not available through MCP: an update of a published app is `storebound play release <aab> --production completed --yes` in a terminal or pipeline (no owner step), the first-ever production release is the owner\'s.',
|
||||
props: { aab: { type: 'string', description: 'Path to the .aab' }, version_code: { type: 'integer', minimum: 1 },
|
||||
track: { type: 'string', enum: ['internal', 'alpha', 'beta'], default: 'internal' }, status: { type: 'string', enum: ['completed', 'draft'], default: 'completed' }, not_sent_for_review: notSentProp },
|
||||
args: i => [...(i.aab ? [String(i.aab)] : []), ...opt('version-code', i.version_code), `--${String(i.track ?? 'internal')}`, String(i.status ?? 'completed'), ...flag('not-sent-for-review', i.not_sent_for_review)] },
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
// Upload an AAB (or reuse an uploaded versionCode) and put it on one or more tracks in one edit, with the "What's new"
|
||||
// of every listing language as release notes.
|
||||
// storebound play release app-release.aab [--internal completed] [--production draft] [--name "1.0.0 (3)"] [--dry]
|
||||
// storebound play release app-release.aab [--internal completed] [--production draft] [--name "1.0.0 (3)"] [--first-release] [--dry]
|
||||
// storebound play release --version-code 3 --production draft
|
||||
// storebound play release app.aab --production completed --not-sent-for-review (commit with changesNotSentForReview=true)
|
||||
// Tracks: internal, alpha (closed), beta (open), production; status completed | draft | inProgress | halted.
|
||||
// Without a track flag: internal, completed. A production release that is not a draft asks for confirmation.
|
||||
// Without a track flag: internal, completed. A production release that is not a draft asks for confirmation (--yes in
|
||||
// scripts). An UPDATE of a published app (the production track holds a completed / inProgress / halted release) needs
|
||||
// no owner step: `play release <aab> --production completed --yes` runs in a pipeline (cli/lib/release-kind.ts). The
|
||||
// first-ever production release with --yes is exit 3 OWNER_STEP unless the owner approved it (--first-release, or
|
||||
// bypass mode).
|
||||
// Draft app (never published): Play only accepts status "draft" on any track; the first production release is sent
|
||||
// from the Console (see the play-first-release skill).
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
|
|
@ -19,6 +23,7 @@ import { usedVersionCodes } from './next-version-code';
|
|||
import { readPlayCopy, refusePlayNotes } from './listing';
|
||||
import { shipNoop, shipPhase, shipReport } from '../lib/ship-step';
|
||||
import { consoleUrl, playTrackPage } from '../lib/console-links';
|
||||
import { enforceFirstRelease, playReleaseKind } from '../lib/release-kind';
|
||||
|
||||
const TRACKS = ['internal', 'alpha', 'beta', 'production'];
|
||||
|
||||
|
|
@ -49,8 +54,20 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
for (const t of tracks) if (!STATUSES.includes(t.status)) throw new Error(`--${t.track}: status must be one of ${STATUSES.join(', ')}`);
|
||||
const fraction = a.get('fraction');
|
||||
const live = tracks.find(t => t.track === 'production' && t.status !== 'draft');
|
||||
const gated = tracks.find(t => t.track === 'production' && (t.status === 'completed' || t.status === 'inProgress'));
|
||||
const notes = readPlayCopy(ctx).filter(c => c.whatsNew).map(c => ({ language: c.lang, text: c.whatsNew }));
|
||||
refusePlayNotes(ctx, notes, `play release notes${a.has('dry') ? ' (dry run)' : ''}`);
|
||||
// update of a published app: no owner step; the first production release: the owner's (exit 3 OWNER_STEP with --yes)
|
||||
let early: Awaited<ReturnType<typeof playClient>> | undefined;
|
||||
const client = async (): Promise<Awaited<ReturnType<typeof playClient>>> => (early ??= await playClient(root, pkg));
|
||||
let kind: ReturnType<typeof playReleaseKind> | null = null;
|
||||
if (gated) {
|
||||
const p0 = await client();
|
||||
const prod = await p0.read(async id => (await p0.call<{ releases?: PlayTrackRelease[] }>('GET', `${p0.API}/edits/${id}/tracks/production`).catch(() => ({ releases: [] }))).releases ?? []);
|
||||
kind = playReleaseKind(prod);
|
||||
if (a.has('dry')) log('play', `${kind.kind === 'update' ? 'update of a published app' : 'FIRST production release (the owner approves it once: --first-release)'}: ${kind.why}`);
|
||||
else if (!enforceFirstRelease(ctx, kind, { store: 'play', what: 'the first production release', consoleUrl: consoleUrl('play.publishing') })) return;
|
||||
}
|
||||
if (live && !a.has('dry') && !(await confirm(ctx, `Release to PRODUCTION as "${live.status}" (public once Google approves)?`))) return;
|
||||
|
||||
if (aab) {
|
||||
|
|
@ -59,7 +76,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (sdkWhy) throw new Error(`${aab}: not released, ${sdkWhy}`);
|
||||
}
|
||||
shipReport({ track: tracks.map(t => t.track).join(','), url: consoleUrl(playTrackPage(tracks.length === 1 ? tracks[0]!.track : undefined)) });
|
||||
const play = await playClient(root, pkg);
|
||||
const play = await client();
|
||||
let released = fixed ?? '';
|
||||
let nothing = false;
|
||||
try {
|
||||
|
|
@ -116,6 +133,6 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
}
|
||||
// machine-readable result for CI scripts (a Jenkins stage reads it instead of calling the Play API itself)
|
||||
if (a.has('json')) printJson({ versionCode: released ? Number(released) : null, sha256: aab ? createHash('sha256').update(readFileSync(aab)).digest('hex') : null, name: a.get('name') ?? null,
|
||||
tracks: tracks.map(t => ({ track: t.track, status: t.status, userFraction: t.status === 'inProgress' ? Number(fraction ?? 0.1) : null })), dry: a.has('dry') });
|
||||
tracks: tracks.map(t => ({ track: t.track, status: t.status, userFraction: t.status === 'inProgress' ? Number(fraction ?? 0.1) : null })), dry: a.has('dry'), ...(kind ? { releaseKind: kind.kind } : {}) });
|
||||
done('play', 'release done');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
// Upload an Android App Bundle to a Google Play testing track.
|
||||
// storebound play upload [<app.aab>] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--pre-check "<cmd with {file}>"] [--evidence <path.json>] [--dry] [--yes] [--json]
|
||||
// Upload an Android App Bundle to a Google Play track (testing tracks, or production for an UPDATE of a published app).
|
||||
// storebound play upload [<app.aab>] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--first-release] [--pre-check "<cmd with {file}>"] [--evidence <path.json>] [--dry] [--yes] [--json]
|
||||
// Default AAB: the newest in build/android (storebound play build), or with --version-code N the one with that
|
||||
// versionCode. Package and versionCode are read from the bundle; the upload is refused when Play already has that
|
||||
// versionCode (exit 1), and under the ship-step contract (--json / --result-file, docs/ship-step.md) it is exit 7
|
||||
// "nothing to do" with the track and state that are there: never a second upload. Release notes: the "What's new" of
|
||||
// every listing language. --dry is read-only (one throwaway edit: version codes and the track are read, the edit is
|
||||
// deleted). A real run asks first (--yes in scripts). --track production sends the release to Google's review (a
|
||||
// pipeline's update step); --status draft keeps it for the Console.
|
||||
// pipeline's update step): no owner gate when the production track already holds a published release (an update of
|
||||
// a live app, cli/lib/release-kind.ts); a first-ever production release with --yes is exit 3 OWNER_STEP unless the owner
|
||||
// approved it (--first-release, or bypass mode). --status draft keeps the release for the Console.
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
|
|
@ -21,6 +23,7 @@ import { usedVersionCodes } from './next-version-code';
|
|||
import { runPreCheck } from '../lib/pre-check';
|
||||
import { shipNoop, shipPhase, shipReport, shipRun, shipWarn } from '../lib/ship-step';
|
||||
import { consoleUrl, playTrackPage } from '../lib/console-links';
|
||||
import { enforceFirstRelease, playReleaseKind } from '../lib/release-kind';
|
||||
|
||||
const TAG = 'upload';
|
||||
export const UPLOAD_TRACKS = ['internal', 'alpha', 'beta', 'production'] as const;
|
||||
|
|
@ -83,11 +86,15 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (shipRun()) { done(TAG, `versionCode ${info.versionCode} is on Google Play already: nothing uploaded`); return; }
|
||||
}
|
||||
if (used.includes(info.versionCode)) throw new Error(`versionCode ${info.versionCode} is already on Google Play: rebuild with --version-code ${nextVersionCode(used)} (storebound play build)`);
|
||||
const summary = { aab: relative(root, aab), packageName: info.packageName, versionCode: info.versionCode, versionName: info.versionName ?? null, sizeMB: Math.round(size / 104857.6) / 10,
|
||||
// production: update of a published app (no owner gate) or the first release (the owner's step)
|
||||
const kind = track === 'production' && status !== 'draft' ? playReleaseKind(current) : null;
|
||||
if (kind && !dry && !enforceFirstRelease(ctx, kind, { store: 'play', what: 'the first production release', consoleUrl: consoleUrl('play.publishing') })) return;
|
||||
const summary = { ...(kind ? { releaseKind: kind.kind } : {}), aab: relative(root, aab), packageName: info.packageName, versionCode: info.versionCode, versionName: info.versionName ?? null, sizeMB: Math.round(size / 104857.6) / 10,
|
||||
track, status, notes: notes.map(n => n.language), replaces: current.map(r => `${r.status} ${(r.versionCodes ?? []).join(',')}`), ...(pre ? { preCheck: pre } : {}) };
|
||||
if (dry) {
|
||||
if (json) { printJson(summary); return; }
|
||||
log(TAG, `${summary.aab}: ${info.packageName} ${info.versionName ?? ''} (${info.versionCode}), ${summary.sizeMB} MB, versionCode free`);
|
||||
if (kind) log(TAG, `${kind.kind === 'update' ? 'update of a published app' : 'FIRST production release (the owner approves it once: --first-release)'}: ${kind.why}`);
|
||||
log(TAG, `would upload it and set track ${track} to ${status} ${info.versionCode}${notes.length ? `, notes in ${notes.length} language(s)` : ', no release notes'}${current.length ? ` (now on ${track}: ${summary.replaces.join('; ')})` : ''}`);
|
||||
done(TAG, 'upload planned (dry run, read-only)');
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ export const DESKTOP_ONLY: ToolDef[] = [
|
|||
version_name: { type: 'string', pattern: '^[0-9A-Za-z.+-]{1,40}$', description: 'versionName (default: from the project)' } },
|
||||
args: i => [...opt('flavor', i.flavor), ...opt('module', i.module), ...opt('version-name', i.version_name)] },
|
||||
{ name: 'play_upload', title: 'Upload an AAB to a testing track', command: ['play', 'upload'], write: true, network: true,
|
||||
description: 'Uploads the newest AAB of the build output to a Google Play testing track (internal, closed or open testing), versionCode checked first. Testers of that track get it; production stays an owner action.',
|
||||
description: 'Uploads the newest AAB of the build output to a Google Play testing track (internal, closed or open testing), versionCode checked first. Testers of that track get it. Production: an update of a published app is `storebound play upload --track production --yes` in a terminal or pipeline (no owner step); the first-ever production release stays the owner\'s.',
|
||||
props: { track: { type: 'string', enum: ['internal', 'alpha', 'beta'], default: 'internal' }, status: { type: 'string', enum: ['completed', 'draft'], default: 'completed' },
|
||||
name: { type: 'string', pattern: '^[^\\x00-\\x1f]{1,50}$', description: 'Release name (default: the version)' } },
|
||||
args: i => [...opt('track', i.track), ...opt('status', i.status), ...opt('name', i.name)] },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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`). |
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'])));
|
||||
|
|
|
|||
|
|
@ -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
259
tests/update-gate.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue