From 1bb43fd4a0b8147d5e223fc25003c53172e09dd4 Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 11:56:07 +0800 Subject: [PATCH] play managed publishing: console plan/verify play-publish-changes, ready-to-publish items in play status, desktop hint --- CHANGELOG.md | 1 + apps/desktop/src/i18n/en.ts | 2 ++ apps/desktop/src/i18n/tr.ts | 2 ++ apps/desktop/src/screens/tabs/Releases.tsx | 16 ++++++++++ cli/console/plan.ts | 6 ++++ cli/console/verify.ts | 4 +++ cli/lib/console-answers.ts | 2 +- cli/lib/console-plan.ts | 2 ++ cli/lib/console-play-plans.ts | 32 +++++++++++++++++++ cli/lib/console-verify.ts | 18 +++++++++++ cli/lib/play-publish.ts | 29 +++++++++++++++++ cli/lib/train.ts | 2 +- cli/mcp-tools.ts | 2 +- cli/play/publish-ready.ts | 18 +++++++++++ cli/play/status.ts | 4 ++- docs/pitfalls.md | 1 + skills/console-autofill/SKILL.md | 2 +- skills/play-first-release/SKILL.md | 3 ++ skills/store-pitfalls/SKILL.md | 11 +++++++ tests/console-play-plans.test.ts | 36 +++++++++++++++++++--- 20 files changed, 184 insertions(+), 9 deletions(-) create mode 100644 cli/lib/play-publish.ts create mode 100644 cli/play/publish-ready.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f63eba..1ec495d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased +- Play managed publishing: `storebound console plan play-publish-changes` (Publishing overview > "Changes ready to publish" > the owner's final "Publish N changes" + its confirm dialog; never-press: Discard changes, Stop managed publishing; `--lookup` lists the ready production releases from the API) and `console verify play-publish-changes` (API: passes when no production release is left ready to publish; or the page text). `play status` (and `--json` `readyToPublish`) prints the ready items and the plan hint, `storebound status` names the plan, and the desktop Releases tab shows the same hint with a button that opens Publishing overview. - Desktop dev update channel: Settings > Updates offers Stable (the npm releases) and Dev (every push to main). Each push to main builds Windows and Linux bundles stamped `-dev.` (above the current stable, below the next one); the VDS signs them and serves `storebound.dev/desktop/dev/latest.json` (newest 5 kept). The app checks its channel at start, every 15 minutes, after sleep and right after a channel switch, installs a strictly newer build quietly (deferred while the window is in use), never downgrades (dev back to stable waits for a newer stable), rewrites its logon autostart on every start, and the tray card shows version and channel. No macOS dev builds (Developer ID signing needs the Mac's GUI session). - `shots` full-bleed layout: `shots.layout: "bleed"` fills the image with the app's own frame (no device, no background) and puts the scene title as a short corner caption (`corner`, `captionScale`, `captionWidth`, `captionInset` per scene) legible through a text shadow and soft glow only; the Play feature graphic shows the logo or `shots.feature.title`. Frames are captured per size at its own pixels in `///.png` (feature: `/feature/`), including the `mac` and `chromebook` sizes; `shots check` / `capture --source folder` verify aspect and native width. `shots.orientation: "landscape"` (bleed only) renders every portrait size landscape (stores accept it). Templates `template-bleed.html`, `feature-bleed.html`. - `storebound desktop`: "opened" now also works when a hidden tray copy is already running. On Windows it prints "opened" only when a process of the installed path has a visible window (the running copy shows it through the single-instance handoff); on macOS / Linux a visible start or a handoff to the running copy counts, otherwise it says only a tray copy runs. The graceful stop waits 3 s on Windows (closing a tray app only hides it), then stops by PID. diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 8424ef3..3e3d6ba 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -154,6 +154,8 @@ export const en = { 'phase.pageUnknown': "Couldn't check the store page", 'phase.pageStale': "couldn't check the store page, as of {when}", 'phase.reviewLive': 'In review (live: {version})', + 'roll.publishReady': 'If the release is approved, publish it in Publishing overview ("Publish N changes"). The agent prepares it with `{cmd}`; the final press is for the owner.', + 'roll.publishOpen': 'Open Publishing overview', 'phase.reviewManaged': 'In review / ready to publish (managed publishing)', 'phase.rejected': 'Rejected', 'phase.testing': 'Testing', diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index 935eac8..8901d14 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -156,6 +156,8 @@ export const tr: Record = { 'phase.pageUnknown': 'Mağaza sayfası kontrol edilemedi', 'phase.pageStale': 'mağaza sayfası kontrol edilemedi, {when} itibarıyla', 'phase.reviewLive': 'İncelemede (yayında: {version})', + 'roll.publishReady': 'Sürüm onaylandıysa Yayınlama özetinde yayınlayın ("Publish N changes"). Ajan `{cmd}` ile hazırlar; son basış sahibine aittir.', + 'roll.publishOpen': 'Yayınlama özetini aç', 'phase.reviewManaged': 'İncelemede / yayına hazır (yönetilen yayınlama)', 'phase.rejected': 'Reddedildi', 'phase.testing': 'Testte', diff --git a/apps/desktop/src/screens/tabs/Releases.tsx b/apps/desktop/src/screens/tabs/Releases.tsx index ee83644..fdf96ac 100644 --- a/apps/desktop/src/screens/tabs/Releases.tsx +++ b/apps/desktop/src/screens/tabs/Releases.tsx @@ -11,6 +11,7 @@ import { WriteButton } from '../../ui/WriteButton'; import { type Key, t } from '../../i18n'; import { ago, date, share } from '../../lib/format'; import { type Lane, STAGES, type TestFlightStatus, trainLanes, versionHistory } from '../../lib/model'; +import { api } from '../../api'; import { useData, useRead, useWrite } from '../../store'; import { Icon, StoreGlyph } from '../../ui/icons'; import { RolloutBar } from '../../ui/charts'; @@ -19,6 +20,8 @@ import { EmptyState, Section, StatusPill, tintStyle, useTint } from '../../ui/ds import { AppleSubmitCard, PlayProductionCard } from './Submit'; import { useContextMenu } from '../../ui/ContextMenu'; import { releaseMenu } from '../../ui/app-menu'; +import { consoleUrl } from '../../../../../cli/lib/console-links'; +import { PUBLISH_PLAN_HINT } from '../../../../../cli/lib/play-publish'; import '../../styles/releases.css'; const STAGE_KEY: Record<(typeof STAGES)[number], Key> = { build: 'stage.build', testing: 'stage.testing', review: 'stage.review', live: 'stage.live', rollout: 'stage.rollout' }; @@ -92,11 +95,24 @@ function LaneRow({ lane, version, st, app }: { lane: Lane; version: string; st: )} )} + {r && r.store === 'android' && r.managed && r.phase === 'review' && } {r && app.project && } ); } +/** Managed publishing: the approved production release waits in Publishing overview ("Publish N changes"). */ +function PublishHint({ st }: { st: AppStatus }) { + const a = st.app?.platforms.android; + const url = consoleUrl('play.publishing', { playDeveloperId: a?.developerId, playAppId: a?.appId }); + return ( +
+ {t('roll.publishReady', { cmd: PUBLISH_PLAN_HINT })} + +
+ ); +} + const SHARES = [1, 2, 5, 10, 20, 50]; /** Rollout controls of one store release: Play staged rollout share / halt / resume / complete, iOS phased release diff --git a/cli/console/plan.ts b/cli/console/plan.ts index 846645b..be96c28 100644 --- a/cli/console/plan.ts +++ b/cli/console/plan.ts @@ -47,6 +47,12 @@ export async function run(ctx: Ctx): Promise { 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); diff --git a/cli/console/verify.ts b/cli/console/verify.ts index 09d0d45..5310af6 100644 --- a/cli/console/verify.ts +++ b/cli/console/verify.ts @@ -39,6 +39,10 @@ export async function run(ctx: Ctx): Promise { 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'); diff --git a/cli/lib/console-answers.ts b/cli/lib/console-answers.ts index a585937..8294abb 100644 --- a/cli/lib/console-answers.ts +++ b/cli/lib/console-answers.ts @@ -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; diff --git a/cli/lib/console-plan.ts b/cli/lib/console-plan.ts index 7eeac67..ff11b8c 100644 --- a/cli/lib/console-plan.ts +++ b/cli/lib/console-plan.ts @@ -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'; diff --git a/cli/lib/console-play-plans.ts b/cli/lib/console-play-plans.ts index da3a979..51978b7 100644 --- a/cli/lib/console-play-plans.ts +++ b/cli/lib/console-play-plans.ts @@ -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 : 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, 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); diff --git a/cli/lib/console-verify.ts b/cli/lib/console-verify.ts index 797e227..b68db83 100644 --- a/cli/lib/console-verify.ts +++ b/cli/lib/console-verify.ts @@ -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); diff --git a/cli/lib/play-publish.ts b/cli/lib/play-publish.ts new file mode 100644 index 0000000..d4f5c41 --- /dev/null +++ b/cli/lib/play-publish.ts @@ -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')})`]; +} diff --git a/cli/lib/train.ts b/cli/lib/train.ts index 1cdb660..86cac78 100644 --- a/cli/lib/train.ts +++ b/cli/lib/train.ts @@ -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' }); diff --git a/cli/mcp-tools.ts b/cli/mcp-tools.ts index 24c7644..c7972ae 100644 --- a/cli/mcp-tools.ts +++ b/cli/mcp-tools.ts @@ -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, diff --git a/cli/play/publish-ready.ts b/cli/play/publish-ready.ts new file mode 100644 index 0000000..14f5dfe --- /dev/null +++ b/cli/play/publish-ready.ts @@ -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 { + 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; +} diff --git a/cli/play/status.ts b/cli/play/status.ts index 3097c3a..74214cc 100644 --- a/cli/play/status.ts +++ b/cli/play/status.ts @@ -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 { // 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 { } 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(' ')}`); diff --git a/docs/pitfalls.md b/docs/pitfalls.md index 8056ceb..4f58aed 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -9,6 +9,7 @@ fails without the fix. | # | date | lesson | guard | |---|---|---|---| +| 94 | 2026-10 | An app with managed publishing was approved but never went live: `play status` said "In review / ready to publish" with no next step, and the Publishing overview "Publish N changes" button (production + Data safety) was easy to miss or to confuse with Discard changes. | `console plan play-publish-changes` (final "Publish N changes" is the owner's, Discard changes / Stop managed publishing never-press), `console verify play-publish-changes` (API), the hint in `play status`, `status` and the desktop Releases tab (`cli/lib/play-publish.ts`, `tests/console-play-plans.test.ts`). | | 93 | 2026-10 | A git-ignored, never-captured frames folder rendered store images with no app or game in them; render only logged 404s and `shots check` said 0 errors. | `shots render` refuses to start while a scene frame is missing (`framesForRender` in `cli/shots/lib.ts`) and fails when any load failed; report.json `missing` is a `frame-missing` error in `shots check` / `validate` (`cli/shots/report.ts`, `tests/shots-missing-frames.test.ts`). | | 90 | 2026-10 | `apple submit --build N --version X --yes` after `apple submit --no-submit --yes` (the documented two-step flow) printed the plan (every item "in it already", then "submit PATCH submitted: true"), logged "not added ...: already in the draft submission" and ended with "nothing to submit" without the PATCH: the submit only ran for new items or an UNRESOLVED_ISSUES submission. | `willSubmit` (cli/lib/review-submit.ts) also submits a READY_FOR_REVIEW draft that already holds the named app version (iap-submit: one of the asked-for items), with the same "not ready yet" retry. `tests/resubmit.test.ts` ("the second step submits the prepared draft"). | | 85 | 2026-10 | `shots record --source web` filmed at the page's CSS size: Chromium's screencast ignores the deviceScaleFactor (maxWidth only shrinks), so a 1080x1920 preview of a 430x932 @2x page was a 2.5x upscale. | `Page.captureScreenshot` with `clip.scale` = dpr (long side capped at 1920), 3 requests in flight, timestamped frames joined by ffmpeg; a warning when the first frame is smaller than asked or the rate is under 20 frames/s. `recordSize` / `captureShortfall` in `cli/shots/devices.ts`, `tests/video.test.ts`. | diff --git a/skills/console-autofill/SKILL.md b/skills/console-autofill/SKILL.md index 1048a99..251dac8 100644 --- a/skills/console-autofill/SKILL.md +++ b/skills/console-autofill/SKILL.md @@ -45,7 +45,7 @@ the UI shows. This is the primary mode. `--all` / MCP `all: true` puts every card's steps inline in fix order and ends with the one owner list: tick the IARC Terms of Use box (before), Send changes for review (after)), its cards `play-privacy-policy`, `play-ads`, `play-sign-in-details`, `play-advertising-id`, `play-government-apps`, `play-financial-features`, `play-health-apps`, and the first production release: - `play-production-countries` and `play-production-release`. `storebound console plan` lists them. + `play-production-countries`, `play-production-release` and `play-publish-changes` (managed publishing). `storebound console plan` lists them. 2. Send the owner the `ownerBatch` of every form you will fill in ONE message before you start (AGENTS.md rule 5): the `ask-owner` answers (write them into the config, so the plan has them; never guess them) and the `before` steps (Play content rating: tick the IARC Terms of Use box on the questionnaire's page 1 first; page 1's Next does diff --git a/skills/play-first-release/SKILL.md b/skills/play-first-release/SKILL.md index c1e7f26..393544a 100644 --- a/skills/play-first-release/SKILL.md +++ b/skills/play-first-release/SKILL.md @@ -103,6 +103,9 @@ products) and answers the compliance facts no file proves, all in one batch at t Managed publishing is off by default: the app goes live automatically once Google approves it. If the owner wants to pick the moment, they turn on Publishing overview > Managed publishing first; record it in `play.managedPublishing` (`play release` and the plan warn while it is off). + With managed publishing on, `play status` shows "production X: In review / ready to publish" after approval and + names `storebound console plan play-publish-changes`: Publishing overview > "Changes ready to publish" > **Publish N + changes** (owner's final button; never Discard changes / Stop managed publishing). `console verify play-publish-changes` reads the API. 13. **Verify.** `storebound validate --play` (no blockers), `storebound play status` (tracks, listings, products), Console dashboard has no open tasks, Publishing overview shows "In review". diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 19f7785..c9fa7d2 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1365,3 +1365,14 @@ that fails without the fix (see CONTRIBUTING.md). with what was found. Opening waits up to 15 s: on Windows for a visible top-level window of a process from the installed path (a running `--hidden` tray copy is shown by the single-instance handler, so a second start without `--hidden` opens it), elsewhere for a non-`--hidden` process or a handoff (`tests/desktop-proc.test.ts`). + +## 114. Managed publishing: approved, but nothing went live +- **What happens:** with `play.managedPublishing: true` Google approves the release and it waits in Publishing overview + ("Changes ready to publish", button "Publish N changes", e.g. production plus Data safety). `play status` only said + "In review / ready to publish". +- **Do:** `storebound console plan play-publish-changes`, drive to Publishing overview, then the owner's "Publish N + changes" and its confirm dialog. Never "Discard changes" or "Stop managed publishing". Check with + `storebound console verify play-publish-changes` (API: no production release left ready to publish). +- **Guard:** `cli/lib/play-publish.ts` (ready list from tracks plus the public store page, the only signal the API + gives), the plan, the verify, the hint in `play status` / `status` / the desktop Releases tab. Non-track changes are + not visible to the API. diff --git a/tests/console-play-plans.test.ts b/tests/console-play-plans.test.ts index b7c6c65..f517dd9 100644 --- a/tests/console-play-plans.test.ts +++ b/tests/console-play-plans.test.ts @@ -7,7 +7,8 @@ import { consoleAnswers, PLAN_FORM_IDS, PLAN_ONLY_FORM_IDS } from '../cli/lib/co import { declarationSaveAllowed, flowAllowed, forbiddenControl, formDef, forbiddenTick, publishingControl } from '../cli/lib/console-forms'; import { consolePlan, IN_PAGE_LOOP, playConsoleReminders, planText } from '../cli/lib/console-plan'; import { APP_CONTENT_CARDS, PLAY_MATERIAL_FACT, startLabel } from '../cli/lib/console-play-plans'; -import { appPrivacyPublished, verifyAppContent, verifyPlayDeclaration, verifyText } from '../cli/lib/console-verify'; +import { readyLines, readyToPublish } from '../cli/lib/play-publish'; +import { appPrivacyPublished, verifyPublishChangesApi, verifyAppContent, verifyPlayDeclaration, verifyText } from '../cli/lib/console-verify'; import { CONSOLE_PLAN_FORMS, TOOLS, validateInput } from '../cli/mcp-tools'; import { playRules, type PlaySnapshot } from '../cli/lib/validate-rules'; @@ -27,7 +28,7 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch 'play-store-settings': 'store-settings', 'play-app-content': 'app-content/overview', 'play-privacy-policy': 'app-content/privacy-policy', 'play-ads': 'app-content/ads-declaration', 'play-sign-in-details': 'app-content/testing-credentials', 'play-advertising-id': 'app-content/ad-id-declaration', 'play-government-apps': 'app-content/government-apps', 'play-financial-features': 'app-content/finance', 'play-health-apps': 'app-content/health', 'play-games-services': 'play-games-services/setup', - 'play-production-countries': 'tracks/production', 'play-production-release': 'tracks/production', + 'play-production-countries': 'tracks/production', 'play-production-release': 'tracks/production', 'play-publish-changes': 'publishing', }; for (const form of PLAN_ONLY_FORM_IDS) { test(form, () => { @@ -45,7 +46,7 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch for (const b of ['Publish', 'Submit', 'Send changes for review']) expect(p.buttons.neverPress).toContain(b); for (const b of p.buttons.allowed) expect(b.class).not.toBe('final-submit'); // an App content card's Save is the agent's (verified 2026-10-13); the owner's button is Send changes for review - if (form.startsWith('play-') && !['play-store-settings', 'play-app-content', 'play-production-countries', 'play-production-release', 'play-games-services'].includes(form)) { + if (form.startsWith('play-') && !['play-store-settings', 'play-app-content', 'play-production-countries', 'play-production-release', 'play-games-services', 'play-publish-changes'].includes(form)) { expect(p.buttons.final).toBe('Send changes for review'); expect(p.buttons.neverPress).not.toContain('Save'); expect(p.buttons.allowed).toContainEqual(expect.objectContaining({ button: 'Save', class: 'declaration-save' })); @@ -58,7 +59,8 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch p.ownerBatch.forEach((o, i) => expect(o.n).toBe(i + 1)); expect(planText(p).indexOf('Owner batch')).toBeLessThan(planText(p).indexOf('Steps:')); expect(p.uiFacts.join('\n')).toContain(PLAY_MATERIAL_FACT); - expect(p.verify.command).toBe(`storebound console verify ${form} ${p.verify.capture.includes("aria-selected") ? "--from" : "--from-text"} -`); + // play-publish-changes verifies through the API (no page text needed) + expect(p.verify.command).toBe(form === 'play-publish-changes' ? 'storebound console verify play-publish-changes' : `storebound console verify ${form} ${p.verify.capture.includes("aria-selected") ? "--from" : "--from-text"} -`); expect(p.inPageLoop).toBe(IN_PAGE_LOOP); expect(planText(p)).toContain('window.__sb.run'); expect(JSON.parse(JSON.stringify(p))).toEqual(p); @@ -328,6 +330,32 @@ describe('the first production release: countries, managed publishing, the owner expect(verifyText('play-production-release', base, 'Publishing overview\nChanges in review').ok).toBe(true); }); + test('publish changes: Publish N changes is the final button, never-press holds Discard / Stop managed publishing, ready list from the API', () => { + const managed = { ...base, play: { ...base.play!, managedPublishing: true } } as ShipConfig; + const p = consolePlan('play-publish-changes', managed, { readyChanges: [{ track: 'production', version: '2.0.0', build: '45', rollout: 'full rollout' }] }); + expect(p.buttons.final).toBe('Publish N changes'); + for (const b of ['Discard changes', 'Stop managed publishing', 'Publish N changes']) expect(p.buttons.neverPress).toContain(b); + expect(p.answers[0]!.text).toContain('production 2.0.0 (45) full rollout'); + expect(p.steps.map(s => s.text).join('\n')).toContain('Changes ready to publish'); + expect(p.ownerBatch.at(-1)!.text).toContain('Publish N changes'); + expect(consolePlan('play-publish-changes', managed).answers[0]!.text).toContain('not read'); + }); + + test('publish changes: ready = managed + completed production the store page does not show; verify by API and by page text', () => { + const tracks = [{ track: 'production', releases: [{ name: '2.0.0 (45)', versionCodes: ['45'], status: 'completed' }] }]; + const ready = readyToPublish({ tracks, publicPage: { state: 'notPublic' }, managedPublishing: true }); + expect(ready).toEqual([{ track: 'production', version: '2.0.0', build: '45', rollout: 'full rollout', liveVersion: null }]); + expect(readyToPublish({ tracks, publicPage: { state: 'notPublic' }, managedPublishing: false })).toEqual([]); + expect(readyToPublish({ tracks, publicPage: { state: 'public', versionName: '2.0.0' }, managedPublishing: true })).toEqual([]); + expect(readyToPublish({ tracks, publicPage: { state: 'public', versionName: '1.9.0' }, managedPublishing: true })[0]!.liveVersion).toBe('1.9.0'); + expect(readyLines(ready).join('\n')).toContain('storebound console plan play-publish-changes'); + expect(readyLines([])).toEqual([]); + expect(verifyPublishChangesApi(ready).ok).toBe(false); + expect(verifyPublishChangesApi([]).ok).toBe(true); + expect(verifyText('play-publish-changes', base, 'Publishing overview\nChanges ready to publish\nPublish 2 changes').ok).toBe(false); + expect(verifyText('play-publish-changes', base, 'Publishing overview\nChanges in review').ok).toBe(true); + }); + test('validate: production with 0 countries is a blocker with a release on the track, a warning without', () => { const snap = { defaultLanguage: 'en-US', expectedLanguages: [], listings: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 }, website: null, appAds: null, appAdsLines: [], adsLikely: false, tracks: [], products: [], expectedProducts: [] } as unknown as PlaySnapshot; expect(playRules({ ...snap, productionCountries: 0 }).find(f => f.code === 'countries')).toMatchObject({ level: 'warn', msg: 'production has 0 countries / regions (the API cannot set them)', fix: 'storebound console plan play-production-countries' });