play: an edit Play will not send for review (400 'set changesNotSentForReview to true', a game 1.0.1 (5) after a rejection) is validated and committed again on the same edit with changesNotSentForReview=true; dry runs validate that way and say so; prints the Publishing overview link (owner's button), records it in .storebound/ for play status; --not-sent-for-review on release / upload / rollout / listing / images and MCP / desktop; pitfall 72

This commit is contained in:
CRTkafa 2026-10-16 05:50:37 +08:00
parent dcca4e86e4
commit 220e9bb072
19 changed files with 305 additions and 41 deletions

View file

@ -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 [<app.aab>] [--track internal|alpha|beta] [--status completed|draft] [--name text] [--dry] [--yes] [--json]"
"storebound play upload [<app.aab>] [--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]",

View file

@ -15,7 +15,7 @@ export function parseArgs(argv: string[], booleans: string[] = []): Args {
const repeated = new Map<string, string[]>();
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; }

View file

@ -153,7 +153,7 @@ async function set(ctx: Ctx): Promise<void> {
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;
}

View file

@ -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 <id> ... 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,

View file

@ -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<T>(fn: (id: string, ctl: EditCtl) => Promise<T>, o: { dry?: boolean; commitQuery?: string }): Promise<T> {
/** 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<T>(fn: (id: string, ctl: EditCtl) => Promise<T>, o: EditOpts): Promise<T> {
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<void> => {
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<T>(fn: (id: string, ctl: EditCtl) => Promise<T>, o: { dry?: boolean; commitQuery?: string; retries?: number } = {}): Promise<T> {
async edit<T>(fn: (id: string, ctl: EditCtl) => Promise<T>, o: EditOpts & { retries?: number } = {}): Promise<T> {
return locked(o.dry ? 'dry edit' : 'edit', async () => {
for (let left = o.retries ?? 3; ; left--) {
try { return await editOnce(fn, o); } catch (err) {

View file

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

View file

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

View file

@ -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<void> {
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 <app.aab> | --version-code N [--internal completed] [--production draft] [--name text] [--dry]');
if (!aab && !fixed) throw new Error('usage: play release <app.aab> | --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<void> {
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).`);

View file

@ -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<string, ReviewPending>;
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 <time>, waiting for Send changes for review: <link>". */
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)';
}

View file

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

View file

@ -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<void> {
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<void> {
// 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<void> {
}
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(' ')}`);
});

View file

@ -1,5 +1,5 @@
// Upload an Android App Bundle to a Google Play testing track.
// storebound play upload [<app.aab>] [--track internal|alpha|beta] [--status completed|draft] [--name text] [--dry] [--yes] [--json]
// storebound play upload [<app.aab>] [--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<void> {
const release: Record<string, unknown> = { 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).`);

View file

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