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:
parent
dcca4e86e4
commit
220e9bb072
19 changed files with 305 additions and 41 deletions
|
|
@ -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]",
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).`);
|
||||
|
|
|
|||
62
cli/play/review-pending.ts
Normal file
62
cli/play/review-pending.ts
Normal 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)';
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(' ')}`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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).`);
|
||||
|
|
|
|||
10
cli/serve.ts
10
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. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue