play-publish-changes: a release is ready only when the store page shows an older version; unknown reads are not determined, never a mismatch

This commit is contained in:
CRTkafa 2026-10-24 12:12:37 +08:00
parent 4e2ffd6b3d
commit 26f9559e1a
6 changed files with 40 additions and 15 deletions

View file

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

@ -95,7 +95,7 @@ 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 && r.store === 'android' && r.managed && r.phase === 'review' && !!r.liveVersion && !r.pageStaleAt && <PublishHint st={st} />}
{r && app.project && <RolloutControls app={app} r={r} />}
</article>
);

View file

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

View file

@ -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<typeof playReleases>[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,
}));

View file

@ -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<ReadyChange[]> {
export async function readyFromApi(root: string, config: ShipConfig): Promise<ReadyChange[] & { undetermined?: boolean }> {
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;
}

View file

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