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

@ -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 `<next patch>-dev.<UTC commit time>` (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 `<frames>/<lang>/<size>/<name>.png` (feature: `<lang>/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.

View file

@ -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',

View file

@ -156,6 +156,8 @@ export const tr: Record<keyof typeof en, string> = {
'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',

View file

@ -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:
)}
</div>
)}
{r && r.store === 'android' && r.managed && r.phase === 'review' && <PublishHint st={st} />}
{r && app.project && <RolloutControls app={app} r={r} />}
</article>
);
}
/** 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 (
<div className="roll-ctl" data-hint="publish-changes">
<span className="small muted">{t('roll.publishReady', { cmd: PUBLISH_PLAN_HINT })}</span>
<button className="btn btn-sm" onClick={() => { api.openExternal(url).catch(() => {}); }}><Icon name="external" />{t('roll.publishOpen')}</button>
</div>
);
}
const SHARES = [1, 2, 5, 10, 20, 50];
/** Rollout controls of one store release: Play staged rollout share / halt / resume / complete, iOS phased release

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(' ')}`);

View file

@ -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`. |

View file

@ -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

View file

@ -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".

View file

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

View file

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