diff --git a/CHANGELOG.md b/CHANGELOG.md
index cb1debc..040af4e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,16 @@
## Unreleased
+- **Play edits Play will not send for review are committed anyway.** After a rejection, with other pending changes or
+ managed publishing, Play answers validate / commit with 400 "Changes cannot be sent for review automatically. Please
+ set the query parameter changesNotSentForReview to true". storebound now validates and commits the same edit again
+ with that parameter (a dry run validates that way and says the real run will commit so), then prints "committed;
+ Play did not send it for review: press Send changes for review in Publishing overview: " (the owner's button).
+ `--not-sent-for-review` on `play release`, `upload`, `rollout`, `listing` and `images` (MCP / desktop
+ `not_sent_for_review`) sets it from the start. The API cannot read that changes wait to be sent, so storebound records
+ its commit in `.storebound/` and `play status` shows "changes committed , waiting for Send changes for review"
+ (`--review-sent` forgets it). Pitfall 72.
+
## 0.8.8 · 2026-10-15
- **Console pages as absolute deep links.** Every finding, fix hint, error and plan step that points to an App Store
diff --git a/cli/data/flags.json b/cli/data/flags.json
index 36cb38f..b9cf5c7 100644
--- a/cli/data/flags.json
+++ b/cli/data/flags.json
@@ -80,6 +80,7 @@
"include-drafts",
"jobs",
"name",
+ "not-sent-for-review",
"off",
"prune",
"src",
@@ -136,6 +137,7 @@
"include-drafts",
"jobs",
"name",
+ "not-sent-for-review",
"prune",
"src",
"traffic",
@@ -177,6 +179,7 @@
"apple screenshots": [
"include-drafts",
"jobs",
+ "not-sent-for-review",
"prune",
"src",
"variant",
@@ -479,6 +482,7 @@
"force",
"from",
"lang",
+ "not-sent-for-review",
"store",
"to",
"translate"
@@ -572,6 +576,7 @@
],
"play images": [
"include-drafts",
+ "not-sent-for-review",
"prune",
"src",
"variant",
@@ -579,6 +584,7 @@
],
"play listing": [
"include-drafts",
+ "not-sent-for-review",
"write"
],
"play next-version-code": [],
@@ -592,6 +598,7 @@
"include-drafts",
"internal",
"name",
+ "not-sent-for-review",
"production",
"version-code",
"write"
@@ -600,6 +607,7 @@
"complete",
"fraction",
"halt",
+ "not-sent-for-review",
"resume",
"track"
],
@@ -608,7 +616,8 @@
"apk"
],
"play status": [
- "allow-price-changes"
+ "allow-price-changes",
+ "review-sent"
],
"play subscriptions": [
"all",
@@ -637,6 +646,7 @@
"play upload": [
"include-drafts",
"name",
+ "not-sent-for-review",
"status",
"track",
"write"
@@ -826,6 +836,7 @@
"no-install",
"no-local",
"no-scan",
+ "not-sent-for-review",
"out",
"play",
"prune",
@@ -996,6 +1007,7 @@
"no-variants",
"no-wait",
"no-web",
+ "not-sent-for-review",
"off",
"offline",
"older-than",
@@ -1030,6 +1042,7 @@
"render",
"resolve-iaps",
"resume",
+ "review-sent",
"scenes",
"scheme",
"sdk-dirs",
@@ -1340,10 +1353,10 @@
"storebound play data-safety [--csv out.csv] [--dry] [--force]"
],
"play images": [
- "storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b] [--prune] [--include-drafts]"
+ "storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b] [--prune] [--include-drafts] [--not-sent-for-review]"
],
"play listing": [
- "storebound play listing [--dry] [--json] [--only en-US,de-DE] [--include-drafts]",
+ "storebound play listing [--dry] [--json] [--only en-US,de-DE] [--include-drafts] [--not-sent-for-review]",
"storebound play listing pull [--write] [--json] live listings -> diff -> (with --write) the Play listing file"
],
"play next-version-code": [
@@ -1354,22 +1367,23 @@
],
"play release": [
"storebound play release app-release.aab [--internal completed] [--production draft] [--name \"1.0.0 (3)\"] [--dry]",
- "storebound play release --version-code 3 --production draft"
+ "storebound play release --version-code 3 --production draft",
+ "storebound play release app.aab --production completed --not-sent-for-review (commit with changesNotSentForReview=true)"
],
"play rollout": [
- "storebound play rollout --fraction 0.2 | --halt | --resume [--fraction 0.1] | --complete [--track production] [--dry] [--yes]"
+ "storebound play rollout --fraction 0.2 | --halt | --resume [--fraction 0.1] | --complete [--track production] [--not-sent-for-review] [--dry] [--yes]"
],
"play share": [
"storebound play share --aab app.aab | --apk app.apk [--dry] [--yes] [--json]"
],
"play status": [
- "storebound play status [--json]"
+ "storebound play status [--json] [--review-sent]"
],
"play subscriptions": [
"storebound play subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--yes] [--allow-price-changes]"
],
"play upload": [
- "storebound play upload [] [--track internal|alpha|beta] [--status completed|draft] [--name text] [--dry] [--yes] [--json]"
+ "storebound play upload [] [--track internal|alpha|beta] [--status completed|draft] [--name text] [--not-sent-for-review] [--dry] [--yes] [--json]"
],
"prices": [
"storebound prices export [--out store/prices.csv] [--store apple|play] [--only ids] [--with-automatic] [--no-subscriptions] [--dry] [--json]",
diff --git a/cli/lib/args.ts b/cli/lib/args.ts
index 92b3a01..c24ac90 100644
--- a/cli/lib/args.ts
+++ b/cli/lib/args.ts
@@ -15,7 +15,7 @@ export function parseArgs(argv: string[], booleans: string[] = []): Args {
const repeated = new Map();
const set = (k: string, v: string) => { flags.set(k, v); repeated.set(k, [...(repeated.get(k) ?? []), v]); };
const _: string[] = [];
- const bool = new Set(['dry', 'yes', 'allow-price-changes', 'help', 'json', 'verbose', 'all-pages', 'apply', 'resume', 'force', 'force-unlock', 'include-drafts', ...booleans]);
+ const bool = new Set(['dry', 'yes', 'allow-price-changes', 'help', 'json', 'verbose', 'all-pages', 'apply', 'resume', 'force', 'force-unlock', 'include-drafts', 'not-sent-for-review', 'review-sent', ...booleans]);
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
if (!a.startsWith('--')) { _.push(a); continue; }
diff --git a/cli/listing-store.ts b/cli/listing-store.ts
index 784e6c5..4bfb839 100644
--- a/cli/listing-store.ts
+++ b/cli/listing-store.ts
@@ -153,7 +153,7 @@ async function set(ctx: Ctx): Promise {
await play.edit(async id => {
if (p.create) await play.call('PUT', `${play.API}/edits/${id}/listings/${lang}`, { title: playName, shortDescription: '', fullDescription: '', ...body });
else await play.call('PATCH', `${play.API}/edits/${id}/listings/${lang}`, body);
- });
+ }, { notSentForReview: ctx.args.has('not-sent-for-review'), summary: `listing ${lang}: ${p.changes.map(c => c.field).join(', ')}` });
done(TAG, `Google Play ${lang}: ${p.changes.map(c => c.field).join(', ')} changed (not public until Google reviews the change)`);
return;
}
diff --git a/cli/mcp-tools.ts b/cli/mcp-tools.ts
index a7fc8f7..8d42d58 100644
--- a/cli/mcp-tools.ts
+++ b/cli/mcp-tools.ts
@@ -31,6 +31,9 @@ const source: JsonSchema = { type: 'string', enum: ['web', 'ios-sim', 'android-e
const APP_NAMES = '^[^,\\x00-\\x1f]{1,120}(,[^,\\x00-\\x1f]{1,120}){0,99}$';
const opt = (flag: string, v: unknown): string[] => (v === undefined || v === null || v === '' ? [] : [`--${flag}`, String(v)]);
const flag = (name: string, v: unknown): string[] => (v === true ? [`--${name}`] : []);
+/** Play edits: commit with changesNotSentForReview=true from the start (pitfall 72; without it storebound retries that
+ * way on Play's refusal). The owner then presses Send changes for review in Publishing overview. */
+export const notSentProp = { type: 'boolean', default: false, description: 'Commit with changesNotSentForReview=true: Play takes the changes but does not send them for review; the owner presses Send changes for review in Publishing overview (storebound also retries this way when Play refuses the automatic send)' } as const;
/** Every form `console plan` / `console verify` know (cli/lib/console-answers.ts PLAN_FORM_IDS; a test keeps them equal). */
export const CONSOLE_PLAN_FORMS = ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience',
@@ -164,10 +167,10 @@ export const TOOLS: ToolDef[] = [
args: i => [...opt('since', i.since), ...opt('lang', i.lang), ...flag('all', i.all_languages)] },
{ name: 'play_listing', title: 'Push Play listing text', command: ['play', 'listing'], write: true, network: true, idempotent: true,
description: 'Google Play app details + title / short / full description per language from the listing file. dry_run prints the plan (old -> new per field); draft languages are skipped (owner terminal run with --include-drafts). Not public until the next review / publish.',
- props: { only }, args: i => opt('only', i.only) },
+ props: { only, not_sent_for_review: notSentProp }, args: i => [...opt('only', i.only), ...flag('not-sent-for-review', i.not_sent_for_review)] },
{ name: 'play_images', title: 'Upload Play graphics', command: ['play', 'images'], write: true, destructive: true, network: true, idempotent: true,
description: 'Replace icon, feature graphic and phone screenshots per Play language from the renders folder (or an A/B variant folder); optional 7" / 10" tablet, TV and Wear sets only when rendered (play.tablets). A missing optional set never deletes what Play has (--prune is an owner terminal run). Slow (minutes): prefer the terminal with a log for many languages.',
- props: { only, variant }, args: i => [...opt('only', i.only), ...opt('variant', i.variant)] },
+ props: { only, variant, not_sent_for_review: notSentProp }, args: i => [...opt('only', i.only), ...opt('variant', i.variant), ...flag('not-sent-for-review', i.not_sent_for_review)] },
{ name: 'play_products', title: 'Sync Play one-time products', command: ['play', 'products'], write: true, network: true, idempotent: true,
description: 'Create one-time products with regional prices and activate them; update listing text of existing ones. dry_run shows per product what would change. Price or region changes of existing products are the owner\'s (a terminal run with --allow-price-changes): here everything else is written and each held change is named ("price change on ... needs the owner", exit 3); activating an existing product needs a terminal confirmation.',
props: { only }, args: i => opt('only', i.only) },
@@ -181,8 +184,8 @@ export const TOOLS: ToolDef[] = [
{ 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.',
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' } },
- args: i => [...(i.aab ? [String(i.aab)] : []), ...opt('version-code', i.version_code), `--${String(i.track ?? 'internal')}`, String(i.status ?? 'completed')] },
+ 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)] },
{ name: 'apple_bundle', title: 'Register bundle id', command: ['apple', 'bundle'], write: true, network: true, idempotent: true,
description: 'Register the bundle id if missing and look up the App Store Connect app record.' },
{ name: 'apple_iap', title: 'Sync App Store IAPs', command: ['apple', 'iap'], write: true, network: true, idempotent: true,
diff --git a/cli/play/client.ts b/cli/play/client.ts
index 2b4fae7..f26be0a 100644
--- a/cli/play/client.ts
+++ b/cli/play/client.ts
@@ -7,6 +7,7 @@ import { noteGoogle } from '../lib/quota';
import { AmbiguousWriteError, isAmbiguous, retrySafe } from '../lib/upsert';
import * as rt from '../lib/rt';
import { withMachineLock } from '../lib/lock';
+import { clearPending, isReviewRefused, notSentMessage, publishingUrl, recordPending, withNotSent } from './review-pending';
/** A read's edit is gone: replaced by a newer edit of the app, committed elsewhere or expired. */
export const EDIT_GONE = /Edit has been deleted|editAlreadyCommitted|already been committed|edit.*(expired|not found)|outside of this Edit/i;
@@ -73,29 +74,57 @@ export async function playClient(root: string, packageName: string) {
/** Controls of an edit: `unchanged()` when fn found nothing to change; the edit is then deleted, not committed. */
interface EditCtl { unchanged(): void }
- async function editOnce(fn: (id: string, ctl: EditCtl) => Promise, o: { dry?: boolean; commitQuery?: string }): Promise {
+ /** Edit options. `notSentForReview` commits with changesNotSentForReview=true from the start (pitfall 72);
+ * `summary` names the change in the local record of such a commit (play status). */
+ interface EditOpts { dry?: boolean; commitQuery?: string; notSentForReview?: boolean; summary?: string }
+
+ async function editOnce(fn: (id: string, ctl: EditCtl) => Promise, o: EditOpts): Promise {
const id = await openOne();
log('play', `edit ${id} opened`);
let same = false;
let committing = false;
+ let notSent = !!o.notSentForReview;
+ const refused = (): void => {
+ notSent = true;
+ log('play', 'Play refuses to send these changes for review automatically (after a rejection, other pending changes or managed publishing): validating again with changesNotSentForReview=true');
+ };
try {
const out = await fn(id, { unchanged: () => { same = true; } });
if (o.dry) {
- await call('POST', `${API}/edits/${id}:validate`);
+ try { await call('POST', `${API}/edits/${id}:validate${withNotSent(undefined, notSent)}`); } catch (err) {
+ if (notSent || !isReviewRefused(err)) throw err;
+ refused();
+ await call('POST', `${API}/edits/${id}:validate${withNotSent(undefined, true)}`);
+ }
log('play', 'dry run: edit validated, discarding');
+ if (notSent) log('play', `dry run: the real run commits with changesNotSentForReview=true; Play then does not send it for review: Send changes for review in Publishing overview is the owner's button: ${publishingUrl()}`);
await closeOne(id, 'dry run');
} else if (same) {
log('play', 'nothing to change: edit discarded, not committed');
await closeOne(id, 'unchanged');
} else {
committing = true;
- try { await call('POST', `${API}/edits/${id}:commit${o.commitQuery ?? ''}`); } catch (err) {
- if (!isAmbiguous(err)) throw err;
- openEdit = null;
- throw new CommitUnknown(`the commit of edit ${id} got no clear answer (${(err as Error).message.slice(0, 200)}): it may be live. Not redone, so nothing is applied twice; check with \`storebound play status\` (or the dry run of this command) before running it again.`);
+ // a commit refused with a 400 leaves the edit open and applies nothing: the same edit id is validated and
+ // committed once more with the parameter (no other request is repeated)
+ const commit = async (): Promise => {
+ try { await call('POST', `${API}/edits/${id}:commit${withNotSent(o.commitQuery, notSent)}`); } catch (err) {
+ if (!isAmbiguous(err)) throw err;
+ openEdit = null;
+ throw new CommitUnknown(`the commit of edit ${id} got no clear answer (${(err as Error).message.slice(0, 200)}): it may be live. Not redone, so nothing is applied twice; check with \`storebound play status\` (or the dry run of this command) before running it again.`);
+ }
+ };
+ try { await commit(); } catch (err) {
+ if (notSent || !isReviewRefused(err)) throw err;
+ refused();
+ await call('POST', `${API}/edits/${id}:validate${withNotSent(undefined, true)}`);
+ await commit();
}
openEdit = null;
- log('play', `edit ${id} committed`);
+ log('play', `edit ${id} committed${notSent ? ' with changesNotSentForReview=true' : ''}`);
+ if (notSent) {
+ recordPending(root, { packageName, editId: id, committedAt: new Date().toISOString(), summary: o.summary ?? 'play edit', forced: !!o.notSentForReview });
+ warn('play', notSentMessage());
+ } else clearPending(root, packageName);
}
return out;
} catch (err) {
@@ -132,8 +161,11 @@ export async function playClient(root: string, packageName: string) {
* Opens an edit, runs fn, then commits (or validates and discards when dry). Any Console save or other edit commit
* invalidates an open edit ("A change was made to the application outside of this Edit"); then the whole edit is
* redone, up to `retries` times. Keep edits small (e.g. one per language for images) so a retry is cheap.
+ * Play's 400 "Changes cannot be sent for review automatically" (pitfall 72) is answered on the same edit: validate
+ * and commit again with changesNotSentForReview=true (a dry run validates that way and says so); the owner then
+ * presses Send changes for review in Publishing overview, recorded in .storebound/ for `play status`.
*/
- async edit(fn: (id: string, ctl: EditCtl) => Promise, o: { dry?: boolean; commitQuery?: string; retries?: number } = {}): Promise {
+ async edit(fn: (id: string, ctl: EditCtl) => Promise, o: EditOpts & { retries?: number } = {}): Promise {
return locked(o.dry ? 'dry edit' : 'edit', async () => {
for (let left = o.retries ?? 3; ; left--) {
try { return await editOnce(fn, o); } catch (err) {
diff --git a/cli/play/images.ts b/cli/play/images.ts
index 5e32cf6..6de73ef 100644
--- a/cli/play/images.ts
+++ b/cli/play/images.ts
@@ -1,6 +1,6 @@
// Play store graphics per language, one edit per language (a Console save in parallel then only costs a retry of that
// language, not of the whole upload).
-// storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b] [--prune] [--include-drafts]
+// storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b] [--prune] [--include-drafts] [--not-sent-for-review]
// Languages: the ones `play listing` pushes (a status=draft or status=translate section is skipped unless
// --include-drafts) and that have a listing on Play. A language Play has no listing for yet (404 "Listing for language
// ... not found") is skipped with an info line, never an error: run `play listing` first.
@@ -221,7 +221,7 @@ export async function run(ctx: Ctx): Promise {
]);
if (!types) ctl.unchanged();
log('play', `${step(i + 1, todo.length, code)}: ${n} images in ${types - deleted} set(s)${deleted ? `, ${deleted} set(s) deleted (--prune)` : ''}`);
- });
+ }, { notSentForReview: ctx.args.has('not-sent-for-review'), summary: `images ${code}` });
}
done('play', 'images done');
}
diff --git a/cli/play/listing.ts b/cli/play/listing.ts
index 521e283..a3b6d67 100644
--- a/cli/play/listing.ts
+++ b/cli/play/listing.ts
@@ -1,5 +1,5 @@
// Play store listing text + app details in one edit.
-// storebound play listing [--dry] [--json] [--only en-US,de-DE] [--include-drafts]
+// storebound play listing [--dry] [--json] [--only en-US,de-DE] [--include-drafts] [--not-sent-for-review]
// Every run first reads Play in a throwaway edit (deleted again) and prints the plan: app details and per language
// create / update with old -> new per field. --dry stops there (nothing is written, not even into that edit); the real
// run asks in a terminal and PUTs only what the plan changes, in one committed edit. Languages whose section is
@@ -159,6 +159,6 @@ export async function run(ctx: Ctx): Promise {
await play.call('PUT', `${play.API}/edits/${id}/listings/${b.language}`, b);
log('play', step(i + 1, todo.length, b.language!));
}
- });
+ }, { notSentForReview: ctx.args.has('not-sent-for-review'), summary: 'listing text' });
done('play', sent ? 'listing done' : 'listing already up to date (the edit was deleted, nothing committed)');
}
diff --git a/cli/play/release.ts b/cli/play/release.ts
index f823ccd..d0d6a45 100644
--- a/cli/play/release.ts
+++ b/cli/play/release.ts
@@ -2,6 +2,7 @@
// 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 --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.
// Draft app (never published): Play only accepts status "draft" on any track; the first production release is sent
@@ -38,7 +39,7 @@ export async function run(ctx: Ctx): Promise {
const a = ctx.args;
const aab = a._.find(x => x.endsWith('.aab'));
const fixed = a.get('version-code');
- if (!aab && !fixed) throw new Error('usage: play release | --version-code N [--internal completed] [--production draft] [--name text] [--dry]');
+ if (!aab && !fixed) throw new Error('usage: play release | --version-code N [--internal completed] [--production draft] [--name text] [--not-sent-for-review] [--dry]');
const tracks = TRACKS.filter(t => a.has(t)).map(t => ({ track: t, status: a.get(t) === 'true' ? 'draft' : a.get(t)! }));
if (!tracks.length) tracks.push({ track: 'internal', status: 'completed' });
for (const t of tracks) if (!STATUSES.includes(t.status)) throw new Error(`--${t.track}: status must be one of ${STATUSES.join(', ')}`);
@@ -80,7 +81,8 @@ export async function run(ctx: Ctx): Promise {
log('play', `${t.track} <- ${code} (${t.status}, notes in ${notes.length} languages)`);
}
if (!changed && !uploaded) ctl.unchanged();
- }, { dry: a.has('dry'), retries: aab ? 0 : 3 });
+ }, { dry: a.has('dry'), retries: aab ? 0 : 3, notSentForReview: a.has('not-sent-for-review'),
+ summary: `release ${a.get('name') ?? (aab ?? `versionCode ${fixed}`)}: ${tracks.map(t => `${t.track} ${t.status}`).join(', ')}` });
} catch (e) {
if (/draft app|status draft/i.test(String(e)))
throw new Error(`${(e as Error).message}\n\nThis app has never been published: the API may only create DRAFT releases. Re-run with --production draft, then send it from the Console (skill play-first-release).`);
diff --git a/cli/play/review-pending.ts b/cli/play/review-pending.ts
new file mode 100644
index 0000000..2bb927d
--- /dev/null
+++ b/cli/play/review-pending.ts
@@ -0,0 +1,62 @@
+// Play changes committed with changesNotSentForReview=true (pitfall 72): Play takes the edit but does not send it for
+// review, the owner presses "Send changes for review" in Publishing overview. The Play Developer API has no read-only
+// way to see that changes wait to be sent (no publishing-state endpoint), so storebound records its own commit in
+// .storebound/play-review-pending.json (gitignored) and `play status` shows it. A later commit that Play sent for
+// review by itself (no refusal) clears it: Play then sends every pending change along. `play status --review-sent`
+// forgets it after the owner pressed the button.
+import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { consoleUrl } from '../lib/console-links';
+
+export interface ReviewPending { packageName: string; editId: string; committedAt: string; summary: string; forced: boolean }
+
+/** Play's refusal of the automatic send for review (after a rejection, pending changes, managed publishing). */
+export const REVIEW_REFUSED = /changesNotSentForReview|cannot be sent for review automatically/i;
+/** Exactly that 400: the message names it, and a reason, when present, is a bad request / failed precondition. */
+export function isReviewRefused(err: unknown): boolean {
+ const s = String(err instanceof Error ? err.message : err);
+ const m = /-> 400 (.*)$/s.exec(s);
+ if (!m || !REVIEW_REFUSED.test(m[1]!)) return false;
+ let reasons: string[] = [];
+ try {
+ const e = (JSON.parse(m[1]!) as { error?: { errors?: { reason?: string }[] } }).error;
+ reasons = (e?.errors ?? []).map(x => x.reason ?? '').filter(Boolean);
+ } catch { /* plain text body: the message alone decides */ }
+ return reasons.every(r => /badRequest|invalid|failedPrecondition|precondition/i.test(r));
+}
+
+/** The query string with changesNotSentForReview=true added to `base` ("?a=1" or ''). */
+export function withNotSent(base: string | undefined, on: boolean): string {
+ const parts = [(base ?? '').replace(/^\?/, ''), on ? 'changesNotSentForReview=true' : ''].filter(Boolean);
+ return parts.length ? `?${parts.join('&')}` : '';
+}
+
+export const publishingUrl = (): string => consoleUrl('play.publishing');
+export const notSentMessage = (): string => `committed; Play did not send it for review: press Send changes for review in Publishing overview: ${publishingUrl()}`;
+
+const file = (root: string): string => join(root, '.storebound', 'play-review-pending.json');
+type Store = Record;
+const readAll = (root: string): Store => {
+ try { return existsSync(file(root)) ? JSON.parse(readFileSync(file(root), 'utf8')) as Store : {}; } catch { return {}; }
+};
+const writeAll = (root: string, s: Store): void => {
+ if (!Object.keys(s).length) { rmSync(file(root), { force: true }); return; }
+ mkdirSync(join(root, '.storebound'), { recursive: true });
+ writeFileSync(file(root), JSON.stringify(s, null, 2) + '\n');
+};
+
+export function recordPending(root: string, p: ReviewPending): void { writeAll(root, { ...readAll(root), [p.packageName]: p }); }
+export function readPending(root: string, packageName: string): ReviewPending | null { return readAll(root)[packageName] ?? null; }
+export function clearPending(root: string, packageName: string): boolean {
+ const s = readAll(root);
+ if (!s[packageName]) return false;
+ delete s[packageName];
+ writeAll(root, s);
+ return true;
+}
+
+/** The status line: "changes committed , waiting for Send changes for review: ". */
+export function pendingLine(p: ReviewPending): string {
+ return `changes committed ${p.committedAt.slice(0, 16).replace('T', ' ')} UTC (${p.summary}), waiting for Send changes for review: ${publishingUrl()}`
+ + ' (recorded by storebound; the API cannot read whether it was pressed since: `storebound play status --review-sent` forgets it)';
+}
diff --git a/cli/play/rollout.ts b/cli/play/rollout.ts
index 5fa9963..3b8d185 100644
--- a/cli/play/rollout.ts
+++ b/cli/play/rollout.ts
@@ -1,5 +1,5 @@
// Staged rollout of the release on a Play track (production by default).
-// storebound play rollout --fraction 0.2 | --halt | --resume [--fraction 0.1] | --complete [--track production] [--dry] [--yes]
+// storebound play rollout --fraction 0.2 | --halt | --resume [--fraction 0.1] | --complete [--track production] [--not-sent-for-review] [--dry] [--yes]
// Start a staged rollout with: storebound play release --version-code N --production inProgress --fraction 0.05
// Halting stops new installs of the update; it is NOT a rollback (users who updated keep it). To roll back, ship a
// fixed build with a higher versionCode. Production changes ask for confirmation unless --yes, except --halt.
@@ -46,6 +46,6 @@ export async function run(ctx: Ctx): Promise {
if (!confirmed) confirmed = await confirm(ctx, `${track}: ${next.summary}. Apply?`);
if (!confirmed) throw new Error('not confirmed, nothing changed');
await play.call('PUT', `${play.API}/edits/${id}/tracks/${track}`, { track, releases: next.releases });
- }, { dry, retries: 1 });
+ }, { dry, retries: 1, notSentForReview: ctx.args.has('not-sent-for-review'), summary: `rollout ${track}` });
done('play', dry ? 'rollout checked (dry run, edit discarded)' : 'rollout updated');
}
diff --git a/cli/play/status.ts b/cli/play/status.ts
index 435c1f4..3097c3a 100644
--- a/cli/play/status.ts
+++ b/cli/play/status.ts
@@ -1,5 +1,7 @@
// Read-only overview: tracks and releases, listing languages, one-time products and their purchase option states.
-// storebound play status [--json]
+// storebound play status [--json] [--review-sent]
+// Changes storebound committed with changesNotSentForReview=true (pitfall 72) are shown from the local record (the API
+// has no read of "waiting for Send changes for review"); --review-sent forgets it after the owner pressed the button.
import { playCategoryOf } from '../lib/config';
import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx';
@@ -8,12 +10,20 @@ import { playPublicPage } from '../lib/play-public';
import { playReleases, playReleaseLabel } from '../lib/portfolio';
import { playClient } from './client';
import { listOneTimeProducts } from './products';
+import { clearPending, pendingLine, publishingUrl, readPending } from './review-pending';
interface Track { track: string; releases?: { name?: string; versionCodes?: string[]; status: string; userFraction?: number }[] }
export async function run(ctx: Ctx): Promise {
const { config, root } = ctx.loaded;
- const play = await playClient(root, need(config.play?.packageName, 'play.packageName'));
+ const pkg = need(config.play?.packageName, 'play.packageName');
+ if (ctx.args.has('review-sent')) {
+ console.log(clearPending(root, pkg) ? `${pkg}: the record of changes waiting for Send changes for review is cleared` : `${pkg}: no changes waiting for Send changes for review were recorded`);
+ return;
+ }
+ const pending = readPending(root, pkg);
+ const reviewPending = pending ? { ...pending, url: publishingUrl(), source: 'local record (.storebound/play-review-pending.json); the API cannot read it' } : null;
+ const play = await playClient(root, pkg);
await play.read(async id => {
const tracks = (await play.call<{ tracks?: Track[] }>('GET', `${play.API}/edits/${id}/tracks`)).tracks ?? [];
const listings = (await play.call<{ listings?: { language: string }[] }>('GET', `${play.API}/edits/${id}/listings`)).listings ?? [];
@@ -22,7 +32,7 @@ export async function run(ctx: Ctx): Promise {
// the App category is not in the API (edits.details has no category field): a console step
const cat = playCategoryOf(config);
const category = { config: cat?.category ?? null, source: cat?.source ?? null, api: false, plan: 'storebound console plan play-store-settings' };
- if (ctx.args.has('json')) { printJson({ details, category, tracks, listings, products, publicPage, managedPublishing: config.play?.managedPublishing ?? null }); return; }
+ if (ctx.args.has('json')) { printJson({ details, category, tracks, listings, products, publicPage, managedPublishing: config.play?.managedPublishing ?? null, reviewPending }); return; }
console.log(`${play.packageName}\n details: ${details.defaultLanguage} · ${details.contactEmail ?? '-'} · ${details.contactWebsite ?? '-'}`);
console.log(` category: ${cat ? `${cat.category} (${cat.source})` : 'not in the config (play.category)'} · not readable by the API: Store settings is a console step (${category.plan})`);
console.log(` listings (${listings.length}): ${listings.map(l => l.language).join(', ') || '-'}`);
@@ -32,6 +42,7 @@ export async function run(ctx: Ctx): Promise {
}
console.log(` public store page: ${publicPage.state === 'public' ? `published (200), live version ${publicPage.versionName ?? (publicPage.varies ? 'varies with device' : 'not shown')}` : publicPage.state === 'notPublic' ? 'not public yet (404)' : `couldn't check it (${publicPage.error ?? 'not read'})`}${publicPage.stale ? ` (last known, ${publicPage.checkedAt.slice(0, 16).replace('T', ' ')} UTC)` : ''}`);
for (const r of playReleases({ tracks, publicPage, managedPublishing: config.play?.managedPublishing ?? null }).filter(x => x.track === 'production')) console.log(` production ${r.version ?? '?'} (${r.build ?? '?'}): ${playReleaseLabel(r)}`);
+ if (pending) console.log(` review: ${pendingLine(pending)}`);
console.log(` products (${products.length}):`);
for (const p of products) console.log(` ${p.productId.padEnd(40)} ${(p.purchaseOptions ?? []).map(o => `${o.purchaseOptionId}:${o.state ?? '?'}`).join(' ')}`);
});
diff --git a/cli/play/upload.ts b/cli/play/upload.ts
index feb8f44..8e432be 100644
--- a/cli/play/upload.ts
+++ b/cli/play/upload.ts
@@ -1,5 +1,5 @@
// Upload an Android App Bundle to a Google Play testing track.
-// storebound play upload [] [--track internal|alpha|beta] [--status completed|draft] [--name text] [--dry] [--yes] [--json]
+// storebound play upload [] [--track internal|alpha|beta] [--status completed|draft] [--name text] [--not-sent-for-review] [--dry] [--yes] [--json]
// Default AAB: the newest in build/android (storebound play build). Package and versionCode are read from the bundle;
// the upload is refused when Play already has that versionCode. 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).
@@ -73,7 +73,7 @@ export async function run(ctx: Ctx): Promise {
const release: Record = { versionCodes: [String(b.versionCode)], status, ...(notes.length ? { releaseNotes: notes } : {}), ...(a.get('name') ? { name: a.get('name') } : {}) };
await play.call('PUT', `${play.API}/edits/${id}/tracks/${track}`, { track, releases: [release] });
log(TAG, `${track} <- ${b.versionCode} (${status})`);
- }, { retries: 0 });
+ }, { retries: 0, notSentForReview: ctx.args.has('not-sent-for-review'), summary: `upload versionCode ${info.versionCode} to ${track} ${status}` });
} catch (e) {
if (/draft app|status draft/i.test(String(e)) && status !== 'draft')
throw new Error(`${(e as Error).message}\n\nThis app has never been published: Play only takes draft releases. Re-run with --status draft and roll it out from the Console (skill play-first-release).`);
diff --git a/cli/serve.ts b/cli/serve.ts
index c750e89..d262a97 100644
--- a/cli/serve.ts
+++ b/cli/serve.ts
@@ -26,7 +26,7 @@ import { emptyStatus, fillAppStatus, lastLine } from './lib/app-status';
import { RENDER_FILE_RE } from './lib/project-files';
import { androidSdk, detectTools } from './lib/tools';
import { compiled, pickCallEnv, runCommand, type RunOptions, type Running } from './lib/runner';
-import { inputSchema, type ToolDef, TOOLS, toArgv, validateInput } from './mcp-tools';
+import { inputSchema, notSentProp, type ToolDef, TOOLS, toArgv, validateInput } from './mcp-tools';
import * as rt from './lib/rt';
type Id = string | number;
@@ -169,10 +169,10 @@ export const OWNER_TOOLS: ToolDef[] = [
{ name: 'owner_play_rollout', title: 'Change the Play staged rollout', command: ['play', 'rollout'], write: true, network: true,
description: 'Google Play staged rollout of a track: set the user share (percent), halt it (not a rollback), resume it, or complete it (100 %).',
props: { action: { type: 'string', enum: ['percent', 'halt', 'resume', 'complete'] }, percent: { type: 'integer', minimum: 1, maximum: 99, description: 'User share in percent (action percent, or resume at a new share)' },
- track: { type: 'string', enum: ['production', 'beta', 'alpha', 'internal'], default: 'production' } },
+ track: { type: 'string', enum: ['production', 'beta', 'alpha', 'internal'], default: 'production' }, not_sent_for_review: notSentProp },
required: ['action'],
args: i => [...(i.action === 'halt' ? ['--halt'] : i.action === 'resume' ? ['--resume'] : i.action === 'complete' ? ['--complete'] : []),
- ...(i.percent !== undefined && (i.action === 'percent' || i.action === 'resume') ? ['--fraction', String(Number(i.percent) / 100)] : []), '--track', String(i.track ?? 'production')] },
+ ...(i.percent !== undefined && (i.action === 'percent' || i.action === 'resume') ? ['--fraction', String(Number(i.percent) / 100)] : []), '--track', String(i.track ?? 'production'), ...(i.not_sent_for_review === true ? ['--not-sent-for-review'] : [])] },
{ name: 'owner_ios_phased', title: 'Change the App Store phased release', command: ['apple', 'release'], write: true, network: true,
description: 'The 7-day phased release of an App Store version: turn it on, pause it (not a rollback), resume it, or release to everyone now (complete).',
props: { action: { type: 'string', enum: ['on', 'pause', 'resume', 'complete'] }, version: { type: 'string', pattern: '^\\d+(\\.\\d+){0,2}$' } },
@@ -214,9 +214,9 @@ export const OWNER_TOOLS: ToolDef[] = [
{ name: 'owner_play_production', title: 'Send a release to Google Play production', command: ['play', 'release'], write: true, network: true,
description: 'Puts the versionCode the owner picked (already on Play, by number) on the production track with the What\'s new of every language: completed (everyone), inProgress (a staged rollout share) or draft (a never-published app: Play takes only drafts; Send changes for review stays a Play Console step). With managed publishing off, Play publishes it by itself once Google approves it.',
props: { version_code: { type: 'string', pattern: '^\\d{1,10}$', description: 'The versionCode the owner picked; never "the latest"' }, status: { type: 'string', enum: ['completed', 'inProgress', 'draft'], default: 'completed' },
- percent: { type: 'integer', minimum: 1, maximum: 99, description: 'User share in percent for inProgress' }, name: { type: 'string', pattern: '^[^\\x00-\\x1f-][^\\x00-\\x1f]{0,49}$', description: 'Release name (default: Play\'s)' } },
+ percent: { type: 'integer', minimum: 1, maximum: 99, description: 'User share in percent for inProgress' }, name: { type: 'string', pattern: '^[^\\x00-\\x1f-][^\\x00-\\x1f]{0,49}$', description: 'Release name (default: Play\'s)' }, not_sent_for_review: notSentProp },
required: ['version_code'],
- args: i => ['--version-code', String(i.version_code), '--production', String(i.status ?? 'completed'), ...(i.status === 'inProgress' && i.percent !== undefined ? ['--fraction', String(Number(i.percent) / 100)] : []), ...opt('name', i.name)] },
+ args: i => ['--version-code', String(i.version_code), '--production', String(i.status ?? 'completed'), ...(i.status === 'inProgress' && i.percent !== undefined ? ['--fraction', String(Number(i.percent) / 100)] : []), ...opt('name', i.name), ...(i.not_sent_for_review === true ? ['--not-sent-for-review'] : [])] },
];
/** Owner actions that send something to review or production: allowed only as owner actions (host token), never in
* the MCP server (AI tools) or any other desktop method. */
diff --git a/docs/commands.md b/docs/commands.md
index b3a998c..8dc72c1 100644
--- a/docs/commands.md
+++ b/docs/commands.md
@@ -52,10 +52,10 @@ run in the background with a log (`storebound shots render > .storebound/render.
| `play data-safety` | API | Data safety form (CSV import); `--csv` writes the file |
| `play build [--version-code N] [--flavor f]` | local + API read | `build.prebuild` first (Capacitor: web build + cap sync; stale copied web assets stop the build), then Gradle `bundleRelease` (Flutter `flutter build appbundle`; Capacitor, React Native, Unity export found) with the next free versionCode and the upload key from env / `.storebound` (passed through the environment, never argv); AAB checked and copied to `build/android/` |
| `play upload [] --track internal\|alpha\|beta` | API | AAB to a testing track with release notes; versionCode checked first; `--dry` is read-only, a real run asks |
-| `play release ` | API | upload + tracks (`--internal completed --production draft`, `--production inProgress --fraction 0.05`), release notes |
+| `play release ` | API | upload + tracks (`--internal completed --production draft`, `--production inProgress --fraction 0.05`), release notes; Play's "cannot be sent for review automatically" 400 is committed again with changesNotSentForReview=true (`--not-sent-for-review` from the start), then Send changes for review is the owner's |
| `play rollout` | API | `--fraction x`, `--halt`, `--resume`, `--complete` (asks first, except halt) |
| `play next-version-code` | API, read-only | next free versionCode |
-| `play status` | API, read-only | tracks, listings, products |
+| `play status [--review-sent]` | API, read-only | tracks, listings, products, changes storebound committed that wait for Send changes for review (local record; `--review-sent` forgets it) |
diff --git a/docs/pitfalls.md b/docs/pitfalls.md
index 529538b..0c48ee2 100644
--- a/docs/pitfalls.md
+++ b/docs/pitfalls.md
@@ -9,6 +9,7 @@ fails without the fix.
| # | date | lesson | guard |
|---|---|---|---|
+| 72 | 2026-10 | A game: after Play production 1.0.0 (3) was rejected (Broken Functionality), the fix 1.0.1 (5) went through upload and track updates, then edits.validate answered 400 "Changes cannot be sent for review automatically. Please set the query parameter changesNotSentForReview to true". Play refuses the automatic send after a rejection, with other pending changes or managed publishing. | `cli/play/client.ts` (edit flow) with `cli/play/review-pending.ts`: on exactly that 400 the same edit is validated and committed again with changesNotSentForReview=true (a dry run validates that way and says so), the Publishing overview deep link is printed (the owner's button), the commit is recorded in .storebound/ and `play status` shows it; `--not-sent-for-review` on play release / upload / rollout / listing / images (MCP `not_sent_for_review`). `tests/play-not-sent-for-review.test.ts`. |
| 71 | 2026-10 | Messages sent the owner to console pages as plain text paths ("App Store Connect > App Information > Age Rating", "Play Console > App content") without a link; the owner had to find the app and the page by hand. | `cli/lib/console-links.ts`: every message carries the absolute deep link (app list / developer chooser when ids are unknown), `url` in finding JSON, an Open button in the desktop; `tests/console-links.test.ts` fails on a console path in cli/ or templates/ without an https URL; agent rule 11 in AGENTS.md. |
| 70 | 2026-10 | A game: the app's first in-app purchase (a starter pack) was not in the 1.0.0 review submission (its items held only the appStoreVersion); it stayed READY_TO_SUBMIT and was noticed after two days in WAITING_FOR_REVIEW. Apple reviews the first product of a type only with an app version; approved without it, it is not sellable. | `firstProductGaps` (`cli/lib/review-submit.ts`, read by `cli/apple/first-products.ts`): WARN in `apple status` (text and --json), `release status` and the desktop checks; blocker in `validate` / `release plan` and the `apple submit` plan; `iap-review` says "ready, not submitted yet". `tests/first-products.test.ts`. |
| 69 | 2026-10 | A web app: `bun add -d storebound` plus the scaffolded `storebound.config.ts` broke `next build`: the package exported only its TypeScript source and shipped no declarations; Bun imports .ts, so every storebound test passed, but tsc never takes a package's .ts source as its types. | The package ships declarations for ES module (`dist/types`) and CommonJS projects (`dist/types-cjs`) and a Node entry (`dist/config.js`); Bun keeps the source (`buildEntry`, `pruneDeclarations` in `tools/build-node.ts`, run by prepack). `tests/package-types.test.ts` type-checks the template against the packed entry under bundler / node16 / nodenext / node10 in both project types with no @types/node and skipLibCheck off, and loads the package with `import()` and `require()` under Node. `check` fails on the template's own placeholders (Example Studio, bare example.com). |
diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md
index 90e73d1..4e8307c 100644
--- a/skills/store-pitfalls/SKILL.md
+++ b/skills/store-pitfalls/SKILL.md
@@ -834,3 +834,21 @@ that fails without the fix (see CONTRIBUTING.md).
- **Guard:** `cli/lib/console-links.ts` (every page key, absolute URL with and without ids; `url` in validate / scan /
declarations / release plan JSON, an Open button in the desktop) and `tests/console-links.test.ts`, which fails on a
console path in cli/ or templates/ without an https URL.
+
+## 72. Play refused to send an edit for review: "set changesNotSentForReview to true"
+- **Symptom:** `storebound play release ... --dry` (or a real run) uploads and updates the tracks, then
+ edits.validate or edits.commit answers 400 "Changes cannot be sent for review automatically. Please set the query
+ parameter changesNotSentForReview to true."
+- **Cause:** after a rejection (here production 1.0.0 (3), Broken Functionality), with other changes pending in the
+ Console or with managed publishing, Play no longer sends an API commit for review by itself. The edit is fine; only
+ the automatic send is refused. storebound <= 0.8.8 stopped there.
+- **Do:** nothing by hand: storebound validates and commits the same edit again with changesNotSentForReview=true
+ (`--not-sent-for-review` sets it from the start; MCP `not_sent_for_review`). A dry run says the real run will commit
+ that way. Then the owner presses **Send changes for review** in Publishing overview (storebound prints the deep link):
+ that button is the owner's, never press or automate it. The API has no read of "waiting for Send changes for
+ review", so storebound records its commit in `.storebound/play-review-pending.json` and `storebound play status`
+ shows "changes committed , waiting for Send changes for review: " (`--review-sent` forgets it; a later
+ commit Play sends by itself clears it).
+- **Guard:** `isReviewRefused` and the edit flow in `cli/play/client.ts` / `cli/play/review-pending.ts` (only that 400;
+ no other request is repeated, the same edit id), `play status` (text and `reviewPending` in --json);
+ `tests/play-not-sent-for-review.test.ts` (fake Play refuses validate / commit once).
diff --git a/tests/helpers/fake-stores.ts b/tests/helpers/fake-stores.ts
index 9cb3e6d..2b6450b 100644
--- a/tests/helpers/fake-stores.ts
+++ b/tests/helpers/fake-stores.ts
@@ -356,6 +356,9 @@ export class FakePlay {
dataSafety: string[] = [];
/** versionCode the next bundle upload reports */
bundleCode = 42;
+ /** How many validate / commit calls without changesNotSentForReview=true Play refuses with its 400 "Changes cannot
+ * be sent for review automatically" (after a rejection, pending changes, managed publishing: pitfall 72). */
+ refuseAutoReview = 0;
handle(method: string, url: string, body: unknown): Response {
const u = new URL(url);
@@ -368,6 +371,10 @@ export class FakePlay {
const e = this.edits.get(id!);
if (!e) return err(404, 'NOT_FOUND', 'Edit has been deleted');
if (method === 'DELETE' && rest === '') { this.edits.delete(id!); return new Response(null, { status: 204 }); }
+ if (method === 'POST' && (rest === ':validate' || rest === ':commit') && this.refuseAutoReview > 0 && u.searchParams.get('changesNotSentForReview') !== 'true') {
+ this.refuseAutoReview--;
+ return json({ error: { code: 400, status: 'INVALID_ARGUMENT', message: 'Changes cannot be sent for review automatically. Please set the query parameter changesNotSentForReview to true. Once committed, the changes in this edit can be sent for review from the Google Play Console UI.', errors: [{ message: 'Changes cannot be sent for review automatically. Please set the query parameter changesNotSentForReview to true.', domain: 'global', reason: 'badRequest' }] } }, 400);
+ }
if (method === 'POST' && rest === ':validate') return json({ id });
if (method === 'POST' && rest === ':commit') { this.live = e; this.edits.delete(id!); this.committed.push(id!); return json({ id }); }
if (rest === '/details') { if (method === 'PUT') e.details = body as Record; return json(e.details); }
diff --git a/tests/play-not-sent-for-review.test.ts b/tests/play-not-sent-for-review.test.ts
new file mode 100644
index 0000000..4bf21be
--- /dev/null
+++ b/tests/play-not-sent-for-review.test.ts
@@ -0,0 +1,104 @@
+// Pitfall 72: after a rejection (or with other pending changes / managed publishing) Play refuses to send an edit for
+// review automatically: validate / commit answer 400 "Changes cannot be sent for review automatically. Please set the
+// query parameter changesNotSentForReview to true". storebound validates and commits the same edit again with that
+// parameter, prints the Publishing overview link (the owner's button), records it in .storebound/ and `play status`
+// shows it. The fake Play store (tests/helpers/fake-stores.ts) refuses as often as `refuseAutoReview` says.
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
+import { existsSync } from 'node:fs';
+import { join } from 'node:path';
+import { setConsoleIds } from '../cli/lib/console-links';
+import { run as playRelease } from '../cli/play/release';
+import { run as playStatus } from '../cli/play/status';
+import { isReviewRefused, readPending, recordPending } from '../cli/play/review-pending';
+import { BASE, capture, playEnv, project, restore } from './helpers/store-mock';
+import { installStores, type Stores, uninstallStores } from './helpers/fake-stores';
+
+const LINK = 'https://play.google.com/console/u/0/developers/1234567890123456789/app/4970000000000000001/publishing';
+const LISTING = '# Play\n\n## English\n\n\n### Play short description\n```\nNotes.\n```\n\n### Play full description\n```\nNotes for everyone.\n```\n\n### What\'s new\n```\nFixes.\n```\n';
+
+let s: Stores;
+beforeAll(() => { playEnv(); setConsoleIds({ playDeveloperId: '1234567890123456789', playAppId: '4970000000000000001' }); });
+afterAll(() => { restore(); setConsoleIds({}); });
+beforeEach(() => { s = installStores(); s.play.live.bundles = [5]; });
+afterEach(() => { uninstallStores(); });
+
+const edits = (what: string): string[] => s.calls.filter(c => c.method === 'POST' && c.path.includes(`:${what}`)).map(c => c.path.replace(/^.*\/edits\//, ''));
+const release = (p: ReturnType, extra: string[] = []) =>
+ capture(() => playRelease(p.ctx(['--version-code', '5', '--internal', 'completed', '--production', 'completed', '--name', '1.0.1 (5)', '--yes', ...extra])));
+
+describe('Play refuses the automatic send for review', () => {
+ test('the 400 is recognised by its message (and a bad-request reason), nothing else', () => {
+ const msg = 'POST /edits/e1:validate -> 400 {"error":{"code":400,"message":"Changes cannot be sent for review automatically. Please set the query parameter changesNotSentForReview to true.","errors":[{"reason":"badRequest"}]}}';
+ expect(isReviewRefused(new Error(msg))).toBe(true);
+ expect(isReviewRefused(new Error(msg.replace('badRequest', 'forbidden')))).toBe(false);
+ expect(isReviewRefused(new Error(msg.replace('-> 400', '-> 403')))).toBe(false);
+ expect(isReviewRefused(new Error('POST /edits/e1:commit -> 400 {"error":{"message":"Version code 5 has already been used."}}'))).toBe(false);
+ });
+
+ test('real run: the commit is refused once, then the same edit is validated and committed with changesNotSentForReview=true', async () => {
+ const p = project(BASE, { 'store/listing.md': LISTING });
+ s.play.refuseAutoReview = 1;
+ const r = await release(p);
+ expect(r.exit ?? 0).toBe(0);
+ expect(edits('commit')).toEqual(['edit-1:commit', 'edit-1:commit?changesNotSentForReview=true']);
+ expect(edits('validate')).toEqual(['edit-1:validate?changesNotSentForReview=true']);
+ expect(s.calls.filter(c => c.method === 'POST' && /\/edits$/.test(c.path))).toHaveLength(1); // one edit, never reopened
+ expect(s.play.committed).toEqual(['edit-1']);
+ expect(s.play.live.tracks.production).toBeDefined();
+ const all = r.out + r.err;
+ expect(all).toContain(`committed; Play did not send it for review: press Send changes for review in Publishing overview: ${LINK}`);
+ const rec = readPending(p.root, 'com.example.notes');
+ expect(rec).toMatchObject({ editId: 'edit-1', forced: false });
+ expect(rec!.summary).toContain('1.0.1 (5)');
+ expect(rec!.summary).toContain('production completed');
+ });
+
+ test('dry run: validate is refused once, validated again with the parameter, no commit, says how the real run commits', async () => {
+ const p = project(BASE, { 'store/listing.md': LISTING });
+ s.play.refuseAutoReview = 1;
+ const r = await release(p, ['--dry']);
+ expect(r.exit ?? 0).toBe(0);
+ expect(edits('validate')).toEqual(['edit-1:validate', 'edit-1:validate?changesNotSentForReview=true']);
+ expect(edits('commit')).toEqual([]);
+ expect(s.play.committed).toEqual([]);
+ expect(s.play.edits.size).toBe(0);
+ expect(r.out + r.err).toContain('the real run commits with changesNotSentForReview=true');
+ expect(r.out + r.err).toContain(LINK);
+ expect(existsSync(join(p.root, '.storebound', 'play-review-pending.json'))).toBe(false);
+ });
+
+ test('--not-sent-for-review sets the parameter from the start (no refused request first)', async () => {
+ const p = project(BASE, { 'store/listing.md': LISTING });
+ s.play.refuseAutoReview = 5;
+ const r = await release(p, ['--not-sent-for-review']);
+ expect(r.exit ?? 0).toBe(0);
+ expect(edits('commit')).toEqual(['edit-1:commit?changesNotSentForReview=true']);
+ expect(edits('validate')).toEqual([]);
+ expect(readPending(p.root, 'com.example.notes')).toMatchObject({ forced: true });
+ expect(r.out + r.err).toContain(LINK);
+ });
+
+ test('a normal commit (sent for review by Play) clears the record', async () => {
+ const p = project(BASE, { 'store/listing.md': LISTING });
+ recordPending(p.root, { packageName: 'com.example.notes', editId: 'edit-0', committedAt: '2026-10-16T09:30:00.000Z', summary: 'listing text', forced: false });
+ const r = await release(p);
+ expect(r.exit ?? 0).toBe(0);
+ expect(edits('commit')).toEqual(['edit-1:commit']);
+ expect(readPending(p.root, 'com.example.notes')).toBeNull();
+ });
+
+ test('play status shows the recorded state; --review-sent forgets it', async () => {
+ const p = project(BASE);
+ recordPending(p.root, { packageName: 'com.example.notes', editId: 'edit-7', committedAt: '2026-10-16T09:30:00.000Z', summary: 'release 1.0.1 (5): production completed', forced: false });
+ let r = await capture(() => playStatus(p.ctx([])));
+ expect(r.out).toContain(`changes committed 2026-10-16 09:30 UTC (release 1.0.1 (5): production completed), waiting for Send changes for review: ${LINK}`);
+ r = await capture(() => playStatus(p.ctx(['--json'])));
+ expect(r.out).toContain('"reviewPending"');
+ expect(r.out).toContain('"editId": "edit-7"');
+ r = await capture(() => playStatus(p.ctx(['--review-sent'])));
+ expect(r.out).toContain('cleared');
+ expect(readPending(p.root, 'com.example.notes')).toBeNull();
+ r = await capture(() => playStatus(p.ctx([])));
+ expect(r.out).not.toContain('waiting for Send changes for review');
+ });
+});