From 26f9559e1ae681701c711ca741988f52580fd424 Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 12:12:37 +0800 Subject: [PATCH] play-publish-changes: a release is ready only when the store page shows an older version; unknown reads are not determined, never a mismatch --- CHANGELOG.md | 2 +- apps/desktop/src/screens/tabs/Releases.tsx | 2 +- cli/lib/console-verify.ts | 7 ++++--- cli/lib/play-publish.ts | 16 +++++++++++++++- cli/play/publish-ready.ts | 10 ++++++---- tests/console-play-plans.test.ts | 18 +++++++++++++----- 6 files changed, 40 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ec495d..b4f69d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +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. +- 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. A release counts as ready to publish only when the public store page positively shows an older version; a 404, a failed or stale page read, a same or unparseable version is never ready (verify says "not determined" instead of a mismatch). - 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/screens/tabs/Releases.tsx b/apps/desktop/src/screens/tabs/Releases.tsx index fdf96ac..17b99ad 100644 --- a/apps/desktop/src/screens/tabs/Releases.tsx +++ b/apps/desktop/src/screens/tabs/Releases.tsx @@ -95,7 +95,7 @@ function LaneRow({ lane, version, st, app }: { lane: Lane; version: string; st: )} )} - {r && r.store === 'android' && r.managed && r.phase === 'review' && } + {r && r.store === 'android' && r.managed && r.phase === 'review' && !!r.liveVersion && !r.pageStaleAt && } {r && app.project && } ); diff --git a/cli/lib/console-verify.ts b/cli/lib/console-verify.ts index b68db83..730997d 100644 --- a/cli/lib/console-verify.ts +++ b/cli/lib/console-verify.ts @@ -436,11 +436,12 @@ export function verifyPublishChanges(text: string): VerifyResult { } /** 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 { +export function verifyPublishChangesApi(ready: { track: string; version: string | null; build: string | null; rollout: string }[] & { undetermined?: boolean }): 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', + const rows: VerifyRow[] = [{ item: 'Production releases ready to publish', expected: 'none left', seen: ready.length ? seen : ready.undetermined ? 'none proven (the store page does not show an older live version; not determined)' : '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.']); + return result('play-publish-changes', 'api', rows, ['The API sees production releases only; Data safety and other non-track changes are not readable.', + ...(ready.undetermined ? ['A release counts as ready only when the public store page shows an OLDER version; a 404, failed read or same version is never ready. Check Publishing overview ("Last published on ...") or pass the page text with --from-text -.'] : [])]); } /** Page-text verification of any form that has one. */ diff --git a/cli/lib/play-publish.ts b/cli/lib/play-publish.ts index d4f5c41..6174390 100644 --- a/cli/lib/play-publish.ts +++ b/cli/lib/play-publish.ts @@ -13,9 +13,23 @@ export const PUBLISH_CONFIRM = 'Publish'; export interface ReadyChange { track: string; version: string | null; build: string | null; rollout: string; liveVersion: string | null } +/** + * Ready only when the public store page POSITIVELY shows an older version than the completed production release + * (both parsed and compared; playReleases sets liveVersion only then) and the page read is fresh. A 404, a failed or + * stale read, "Varies with device", a same or newer version, or an unparseable version is never "ready": a release + * already published (managed publishing, "Last published on ...") must not be reported as waiting. + */ +const isReady = (r: ReturnType[number]): boolean => r.track === 'production' && r.managed === true && r.phase === 'review' && !!r.liveVersion && !r.pageStaleAt && !!r.version; + +/** True when a completed managed production release exists but the store page cannot tell whether it is published. */ +export function readyUndetermined(data: unknown): boolean { + const rs = playReleases(data); + return (data as { managedPublishing?: unknown })?.managedPublishing === true && rs.some(r => r.track === 'production' && r.state === 'completed' && !isReady(r) && r.phase !== 'live'); +} + /** 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 => ({ + return playReleases(data).filter(isReady).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, })); diff --git a/cli/play/publish-ready.ts b/cli/play/publish-ready.ts index 14f5dfe..705400b 100644 --- a/cli/play/publish-ready.ts +++ b/cli/play/publish-ready.ts @@ -1,18 +1,20 @@ // 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 { type ReadyChange, readyToPublish, readyUndetermined } from '../lib/play-publish'; import { playPublicPage } from '../lib/play-public'; import { playClient } from './client'; -export async function readyFromApi(root: string, config: ShipConfig): Promise { +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[] = []; + let out: ReadyChange[] & { undetermined?: boolean } = []; 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 }); + const data = { tracks, publicPage, managedPublishing: config.play?.managedPublishing ?? null }; + out = readyToPublish(data); + out.undetermined = !out.length && readyUndetermined(data); }); return out; } diff --git a/tests/console-play-plans.test.ts b/tests/console-play-plans.test.ts index f517dd9..c65df5d 100644 --- a/tests/console-play-plans.test.ts +++ b/tests/console-play-plans.test.ts @@ -7,7 +7,7 @@ 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 { readyLines, readyToPublish } from '../cli/lib/play-publish'; +import { readyLines, readyToPublish, readyUndetermined } 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'; @@ -343,11 +343,19 @@ describe('the first production release: countries, managed publishing, the owner 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([]); + const ready = readyToPublish({ tracks, publicPage: { state: 'public', versionName: '1.9.0' }, managedPublishing: true }); + expect(ready).toEqual([{ track: 'production', version: '2.0.0', build: '45', rollout: 'full rollout', liveVersion: '1.9.0' }]); + expect(readyToPublish({ tracks, publicPage: { state: 'public', versionName: '1.9.0' }, 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'); + // already published (real case: 2.0.0 / 2000001 full rollout, "Last published on ..."): never ready, whatever the page says + const live = [{ track: 'production', releases: [{ name: '2.0.0 (2000001)', versionCodes: ['2000001'], status: 'completed' }] }]; + for (const publicPage of [{ state: 'public', versionName: '2.0.0' }, { state: 'public', versionName: '2.1.0' }, { state: 'public', versionName: null }, { state: 'public', varies: true }, { state: 'notPublic' }, { state: 'unknown' }, { state: 'public', versionName: '1.9.0', stale: true, checkedAt: '2026-10-24T00:00:00Z' }]) { + expect({ publicPage, ready: readyToPublish({ tracks: live, publicPage, managedPublishing: true }) }).toEqual({ publicPage, ready: [] }); + } + const unk = Object.assign([], { undetermined: readyUndetermined({ tracks: live, publicPage: { state: 'notPublic' }, managedPublishing: true }) }); + expect(unk.undetermined).toBe(true); + expect(verifyPublishChangesApi(unk).ok).toBe(true); + expect(verifyPublishChangesApi(unk).rows[0]!.seen).toContain('not determined'); expect(readyLines(ready).join('\n')).toContain('storebound console plan play-publish-changes'); expect(readyLines([])).toEqual([]); expect(verifyPublishChangesApi(ready).ok).toBe(false);