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
|
|
@ -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).`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue