play managed publishing: console plan/verify play-publish-changes, ready-to-publish items in play status, desktop hint

This commit is contained in:
CRTkafa 2026-10-24 11:56:07 +08:00
parent 29a9683ab7
commit 1bb43fd4a0
20 changed files with 184 additions and 9 deletions

View file

@ -47,6 +47,12 @@ export async function run(ctx: Ctx): Promise<void> {
const { projectSignals } = await import('../lib/project-signals');
ids.signals = projectSignals(detectProject(root));
} catch { /* not scanned */ }
if (form === 'play-publish-changes' && args.has('lookup') && cfg.play?.packageName) {
try {
const { readyFromApi } = await import('../play/publish-ready');
ids.readyChanges = await readyFromApi(root, cfg);
} catch { /* not read: the plan says so */ }
}
if (args.has('all') && form !== 'play-app-content') throw new Error('console plan: --all is for play-app-content (every App content card inline)');
const plan = consolePlan(form, cfg, ids, { all: args.has('all') });
if (json) return printJson(plan);

View file

@ -39,6 +39,10 @@ export async function run(ctx: Ctx): Promise<void> {
const text = inline ?? await readText(from!);
if (!text.trim()) throw new Error('console verify: the page text is empty');
r = verifyText(form, config, text);
} else if (form === 'play-publish-changes') {
const { readyFromApi } = await import('../play/publish-ready');
const { verifyPublishChangesApi } = await import('../lib/console-verify');
r = verifyPublishChangesApi(await readyFromApi(root, config));
} else if (form === 'apple-app-record' || form === 'apple-age-rating') {
const bundleId = config.apple?.bundleId;
if (!bundleId) throw new Error('console verify: apple.bundleId is not set');

View file

@ -29,7 +29,7 @@ export type FormId = typeof FORM_IDS[number];
* API cannot set), the App content overview and its declarations without a fill form (cli/lib/console-play-plans.ts). */
export const PLAN_ONLY_FORM_IDS = ['play-store-settings', 'play-app-content', 'play-privacy-policy', 'play-ads', 'play-sign-in-details',
'play-advertising-id', 'play-government-apps', 'play-financial-features', 'play-health-apps', 'play-production-countries', 'play-production-release',
'play-games-services'] as const;
'play-games-services', 'play-publish-changes'] as const;
export type PlanOnlyFormId = typeof PLAN_ONLY_FORM_IDS[number];
/** Every form `console plan` / `console verify` know. */
export const PLAN_FORM_IDS = [...FORM_IDS, ...PLAN_ONLY_FORM_IDS] as const;

View file

@ -108,6 +108,8 @@ export interface PlanIds {
demoAccount?: boolean;
/** What the project scan found (ad / attribution SDKs, the AD_ID permission, support and AR code); undefined: not scanned. */
signals?: ProjectSignals;
/** play-publish-changes: the production releases ready to publish per the API (null/undefined: not read). */
readyChanges?: { track: string; version: string | null; build: string | null; rollout: string }[] | null;
}
const ASC = 'https://appstoreconnect.apple.com';

View file

@ -20,6 +20,7 @@ import { NEVER_PRESS, PLAY_NEVER_PRESS, playAppList } from './console-forms';
import type { ConsolePlan, PlanAnswer, PlanIds, PlanStatus, PlanStep } from './console-plan';
import { AD_ID_PERMISSION, type ProjectSignals } from './project-signals';
import { playGamesPart } from './console-games-plan';
import { PUBLISH_BUTTON, PUBLISH_CONFIRM } from './play-publish';
const PLAY = 'https://play.google.com/console';
@ -503,10 +504,41 @@ function productionRelease(config: ShipConfig, p: PlanIds): Part {
};
}
function publishChanges(config: ShipConfig, p: PlanIds): Part {
const ready = p.readyChanges ?? null;
const readyText = ready === null ? 'not read (add --lookup, or run `storebound play status`)' : ready.length ? ready.map(r => `${r.track} ${r.version ?? '?'} (${r.build ?? '?'}) ${r.rollout}`).join('; ') : 'nothing ready to publish per the API';
return {
title: 'Play Console: publish the approved changes (managed publishing)',
start: start(config, p, 'publishing', ['Publishing overview (left menu)']),
steps: steps([
{ text: 'Precondition: `storebound play status` shows "production <version>: In review / ready to publish" (managed publishing on, Google approved).' },
{ text: 'Open Publishing overview. The section "Changes ready to publish" lists every approved change (e.g. the production release and Data safety); read the list as text and compare it with the ready changes below.' },
{ text: `Stop. The owner presses "${PUBLISH_BUTTON}" (N = the number in the list) and then confirms in its dialog (button "${PUBLISH_CONFIRM}"; a real mouse click, a JS .click() may do nothing). It makes every listed change live at once.`, answers: ['ready'] },
{ text: 'Verify: `storebound console verify play-publish-changes` (API: no production release is left ready to publish) or pipe the page text to `--from-text -`.' },
]),
answers: [ans('ready', 'Changes ready to publish (API)', readyText, 'certain', 'play status (public store page + tracks)', { note: 'The API sees production releases only; Data safety, listing and other non-track changes show in the console list only.', where: 'Publishing overview > Changes ready to publish' })],
final: PUBLISH_BUTTON,
neverPress: ['Publish N changes', 'Send changes for review', 'Submit N changes for review', 'Restart review', 'Discard changes', 'Stop managed publishing', 'Turn off managed publishing', 'Halt rollout', 'Unpublish app', 'Deactivate', ...NEVER_PRESS],
allowed: [
{ button: 'Publishing overview (left menu), expand Changes ready to publish', class: 'navigation', where: 'read-only: lists the approved changes' },
],
uiFacts: [
'Managed publishing (play.managedPublishing: true): after approval the changes wait in Publishing overview > "Changes ready to publish"; the button is "Publish N changes" (N counts every change, e.g. production full rollout + Data safety) and opens a confirm dialog.',
'The Developer API has no publishing-state endpoint: storebound infers "ready to publish" from a completed production release the public store page does not show yet.',
'Never "Discard changes" or "Stop managed publishing" (it would unpublish or drop the approved changes).',
managedPublishingNote(config),
PLAY_MATERIAL_FACT,
],
verify: { command: 'storebound console verify play-publish-changes', source: 'page-text', capture: PAGE_TEXT, expect: ['No production release is left ready to publish (API), or the page shows no "Publish N changes" button / "Changes ready to publish".'] },
guidance: ['Open: Publishing overview > Changes ready to publish'],
};
}
export function playOnlyPart(form: Exclude<PlanOnlyFormId, 'play-app-content'>, config: ShipConfig, p: PlanIds): Part {
switch (form) {
case 'play-production-countries': return productionCountries(config, p);
case 'play-production-release': return productionRelease(config, p);
case 'play-publish-changes': return publishChanges(config, p);
case 'play-store-settings': return storeSettings(config, p);
case 'play-privacy-policy': return privacyPolicy(config, p);
case 'play-ads': return adsDeclaration(config, p);

View file

@ -426,11 +426,29 @@ export function verifyProductionRelease(text: string): VerifyResult {
return result('play-production-release', 'page-text', rows);
}
/** Publishing overview as text (or the API's ready list): nothing left to publish. */
export function verifyPublishChanges(text: string): VerifyResult {
const f = fold(text);
const pending = /publish \d+ changes?|changes ready to publish/.test(f) && !/no changes ready to publish|0 changes ready/.test(f);
const rows: VerifyRow[] = [{ item: 'Changes ready to publish', expected: 'none left', seen: pending ? 'changes ready to publish' : 'none listed', status: pending ? 'mismatch' : 'match',
...(pending ? { note: 'the owner presses "Publish N changes" and confirms' } : {}) }];
return result('play-publish-changes', 'page-text', rows);
}
/** The same check from the API's ready list (`readyToPublish`). */
export function verifyPublishChangesApi(ready: { track: string; version: string | null; build: string | null; rollout: string }[]): VerifyResult {
const seen = ready.map(r => `${r.track} ${r.version ?? '?'} (${r.build ?? '?'}) ${r.rollout}`).join('; ');
const rows: VerifyRow[] = [{ item: 'Production releases ready to publish', expected: 'none left', seen: ready.length ? seen : 'none', status: ready.length ? 'mismatch' : 'match',
...(ready.length ? { note: 'the owner presses "Publish N changes" and confirms (storebound console plan play-publish-changes)' } : {}) }];
return result('play-publish-changes', 'api', rows, ['The API sees production releases only; Data safety and other non-track changes are not readable.']);
}
/** Page-text verification of any form that has one. */
export function verifyText(form: PlanFormId, config: ShipConfig, text: string): VerifyResult {
if (form === 'play-app-content') return verifyAppContent(text);
if (form === 'play-production-countries') return verifyProductionCountries(config, text);
if (form === 'play-production-release') return verifyProductionRelease(text);
if (form === 'play-publish-changes') return verifyPublishChanges(text);
if (form === 'play-games-services') return result(form, 'page-text', verifyPlayGames(config, text));
if (form === 'play-store-settings' || form === 'play-privacy-policy' || form === 'play-ads' || form === 'play-sign-in-details' || form === 'play-advertising-id'
|| form === 'play-government-apps' || form === 'play-financial-features' || form === 'play-health-apps') return verifyPlayDeclaration(form, config, text);

29
cli/lib/play-publish.ts Normal file
View file

@ -0,0 +1,29 @@
// Play managed publishing: what waits in Publishing overview as "Changes ready to publish" (button "Publish N changes").
// The Developer API has no publishing-state endpoint; `play status` decides it the same way portfolio.playReleases does:
// a completed production release that the public store page does not show yet (404, or an older version) while
// play.managedPublishing is on is "In review / ready to publish". Pure. Non-track changes (Data safety, listing, content
// rating) are listed by the console page only; the API cannot see them.
import { playReleases } from './portfolio';
import { consoleUrl } from './console-links';
export const PUBLISH_PLAN_HINT = 'storebound console plan play-publish-changes';
/** The owner's final button: "Publish N changes" (N is the page's count) and its confirm dialog. */
export const PUBLISH_BUTTON = 'Publish N changes';
export const PUBLISH_CONFIRM = 'Publish';
export interface ReadyChange { track: string; version: string | null; build: string | null; rollout: string; liveVersion: string | null }
/** Ready-to-publish production releases from the `play status --json` data ({ tracks, publicPage, managedPublishing }). */
export function readyToPublish(data: unknown): ReadyChange[] {
return playReleases(data).filter(r => r.track === 'production' && r.managed === true && r.phase === 'review').map(r => ({
track: r.track ?? 'production', version: r.version, build: r.build,
rollout: r.fraction !== null && r.fraction < 1 ? `${Math.round(r.fraction * 1000) / 10}% staged` : 'full rollout', liveVersion: r.liveVersion ?? null,
}));
}
/** Status lines for `play status` / `status`: the items and the hint (empty when nothing is ready). */
export function readyLines(items: ReadyChange[]): string[] {
if (!items.length) return [];
return [` ready to publish (managed publishing): ${items.map(i => `${i.track} ${i.version ?? '?'} (${i.build ?? '?'}) ${i.rollout}${i.liveVersion ? `, live: ${i.liveVersion}` : ''}`).join(' | ')}; non-track changes (Data safety, listing) show only in the console`,
` next: ${PUBLISH_PLAN_HINT} (${consoleUrl('play.publishing')})`];
}

View file

@ -239,7 +239,7 @@ function storeSteps(store: 'ios' | 'android', v: string, r: StoreRelease | null,
break;
case 'review':
if (store === 'android' && r!.track === 'production') {
steps.push({ kind: r!.managed ? 'owner' : 'wait', text: `${v}: ${playReleaseLabel(r!)}`, fix: r!.managed ? `${consoleLink('play.publishing')} (publish once approved)` : 'storebound play status', ...(r!.managed ? { url: consoleUrl('play.publishing') } : {}) });
steps.push({ kind: r!.managed ? 'owner' : 'wait', text: `${v}: ${playReleaseLabel(r!)}`, fix: r!.managed ? `${consoleLink('play.publishing')} (publish once approved; \`storebound console plan play-publish-changes\`)` : 'storebound play status', ...(r!.managed ? { url: consoleUrl('play.publishing') } : {}) });
break;
}
steps.push({ kind: 'wait', text: `${v} is in review (${r!.state}); nothing to do until the store answers`, fix: store === 'ios' ? 'storebound apple status --wait-review' : 'storebound play status' });

View file

@ -40,7 +40,7 @@ export const notSentProp = { type: 'boolean', default: false, description: 'Comm
/** 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',
'play-store-settings', 'play-app-content', 'play-privacy-policy', 'play-ads', 'play-sign-in-details', 'play-advertising-id', 'play-government-apps',
'play-financial-features', 'play-health-apps', 'play-production-countries', 'play-production-release', 'play-games-services'];
'play-financial-features', 'play-health-apps', 'play-production-countries', 'play-production-release', 'play-games-services', 'play-publish-changes'];
export const TOOLS: ToolDef[] = [
{ name: 'check', title: 'Offline config check', command: ['check'], write: false, network: false,

18
cli/play/publish-ready.ts Normal file
View file

@ -0,0 +1,18 @@
// Read-only: the production releases waiting in Publishing overview (managed publishing), from the tracks and the public store page.
import type { ShipConfig } from '../lib/config';
import { need } from '../lib/ctx';
import { type ReadyChange, readyToPublish } from '../lib/play-publish';
import { playPublicPage } from '../lib/play-public';
import { playClient } from './client';
export async function readyFromApi(root: string, config: ShipConfig): Promise<ReadyChange[]> {
const pkg = need(config.play?.packageName, 'play.packageName');
const play = await playClient(root, pkg);
let out: ReadyChange[] = [];
await play.read(async id => {
const tracks = (await play.call<{ tracks?: unknown[] }>('GET', `${play.API}/edits/${id}/tracks`)).tracks ?? [];
const publicPage = await playPublicPage(play.packageName);
out = readyToPublish({ tracks, publicPage, managedPublishing: config.play?.managedPublishing ?? null });
});
return out;
}

View file

@ -10,6 +10,7 @@ import { playPublicPage } from '../lib/play-public';
import { playReleases, playReleaseLabel } from '../lib/portfolio';
import { playClient } from './client';
import { listOneTimeProducts } from './products';
import { readyLines, readyToPublish } from '../lib/play-publish';
import { clearPending, pendingLine, publishingUrl, readPending } from './review-pending';
interface Track { track: string; releases?: { name?: string; versionCodes?: string[]; status: string; userFraction?: number }[] }
@ -32,7 +33,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, reviewPending }); return; }
if (ctx.args.has('json')) { printJson({ details, category, tracks, listings, products, publicPage, managedPublishing: config.play?.managedPublishing ?? null, reviewPending, readyToPublish: readyToPublish({ tracks, publicPage, managedPublishing: config.play?.managedPublishing ?? null }) }); 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(', ') || '-'}`);
@ -42,6 +43,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)}`);
for (const l of readyLines(readyToPublish({ tracks, publicPage, managedPublishing: config.play?.managedPublishing ?? null }))) console.log(l);
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(' ')}`);