play: old testing builds below production listed with their replace command (status, validate); 'Not live yet' instead of 'In review'; commit redone without changesNotSentForReview on the auto-send 400; pitfalls #129
This commit is contained in:
parent
83d351cf42
commit
9c001d1ca9
13 changed files with 130 additions and 15 deletions
|
|
@ -161,11 +161,15 @@ export function playReleases(data: unknown): StoreRelease[] {
|
|||
return out;
|
||||
}
|
||||
|
||||
/** A completed Play production release the store page does not show yet. The API has no review state: it may be in
|
||||
* review, or blocked before review (an old build on another track with a policy issue, pitfall 129). Never "In review". */
|
||||
export const PLAY_NOT_LIVE = 'Not live yet: in review or blocked before review (the API cannot tell; see Publishing overview)';
|
||||
|
||||
/** Plain words for a Play release's phase, decided from the store page (CLI text; the desktop has the same words in
|
||||
* i18n, apps/desktop/src/ui/kit.tsx phaseLabel). */
|
||||
export function playReleaseLabel(r: StoreRelease): string {
|
||||
const base = ((): string => {
|
||||
if (r.phase === 'review' || r.phase === 'sent') return r.managed ? 'In review / ready to publish (managed publishing)' : r.liveVersion ? `In review (live: ${r.liveVersion})` : 'In review';
|
||||
if (r.phase === 'review' || r.phase === 'sent') return r.managed ? 'In review / ready to publish (managed publishing)' : r.liveVersion ? `${PLAY_NOT_LIVE} (live: ${r.liveVersion})` : PLAY_NOT_LIVE;
|
||||
if (r.phase === 'released') return "Couldn't check the store page";
|
||||
if (r.phase === 'live') return 'Live';
|
||||
if (r.phase === 'rollout') return `Rolling out ${Math.round((r.fraction ?? 0) * 1000) / 10}%`;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Store readiness rules for `storebound validate`. Pure: the command collects a snapshot from both APIs (read-only),
|
||||
// these functions turn it into an ordered list of findings. Order = the order you would fix things in.
|
||||
|
||||
import { oldTrackBuilds } from '../play/old-builds';
|
||||
import { consoleLink, consoleUrl } from './console-links';
|
||||
import { describePending, type Pending, repairable } from '../apple/screenshot-health';
|
||||
import { type FixAction, fixAppleListing, fixPlayListing, fixReviewDetails } from './fix-actions';
|
||||
|
|
@ -409,6 +410,7 @@ export function playRules(s: PlaySnapshot): Finding[] {
|
|||
if (r.status === 'halted') add('warn', 'tracks', `${t.track}: release ${r.versionCodes.join(',')} is halted`, 'storebound play rollout --resume (owner)');
|
||||
if (r.status === 'inProgress') add('ok', 'tracks', `${t.track}: rollout ${Math.round((r.userFraction ?? 0) * 100)}% of ${r.versionCodes.join(',')}`);
|
||||
}
|
||||
for (const o of oldTrackBuilds(s.tracks)) add('warn', 'old-builds', `${o.track}: old build ${o.versionCodes.join(',')} (${o.status}) is still active below production ${o.productionCode}; Play reviews it with every submission and a policy issue in it (e.g. READ_MEDIA_IMAGES / READ_MEDIA_VIDEO) blocks Send changes for review (pitfall 129)`, `storebound play release --version-code ${o.productionCode} ${['internal','alpha','beta'].includes(o.track) ? '--' + o.track : '--track ' + o.track} completed`);
|
||||
if (anyRelease && !f.some(x => x.code === 'tracks')) add('ok', 'tracks', s.tracks.filter(t => t.releases.length).map(t => `${t.track}: ${t.releases.map(r => r.status).join('/')}`).join(', '));
|
||||
|
||||
const prods = new Map(s.products.map(p => [p.productId, p] as const));
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { noteGoogle } from '../lib/quota';
|
|||
import { AmbiguousWriteError, isAmbiguous, retrySafe } from '../lib/upsert';
|
||||
import * as rt from '../lib/rt';
|
||||
import { withMachineLock } from '../lib/lock';
|
||||
import { clearPending, isReviewRefused, notSentMessage, publishingUrl, recordPending, withNotSent } from './review-pending';
|
||||
import { clearPending, isAutoSendOnly, isReviewRefused, notSentMessage, publishingUrl, recordPending, withNotSent } from './review-pending';
|
||||
|
||||
/** A read's edit is gone: replaced by a newer edit of the app, committed elsewhere or expired. */
|
||||
export const EDIT_GONE = /Edit has been deleted|editAlreadyCommitted|already been committed|edit.*(expired|not found)|outside of this Edit/i;
|
||||
|
|
@ -118,9 +118,15 @@ export async function playClient(root: string, packageName: string) {
|
|||
}
|
||||
};
|
||||
try { await commit(); } catch (err) {
|
||||
if (notSent || !isReviewRefused(err)) throw err;
|
||||
refused();
|
||||
await commit();
|
||||
if (notSent && isAutoSendOnly(err)) {
|
||||
notSent = false;
|
||||
log('play', 'Play answered "Changes are sent for review automatically" (managed publishing is off): committing again without changesNotSentForReview; Play sends the changes for review itself');
|
||||
await commit();
|
||||
} else {
|
||||
if (notSent || !isReviewRefused(err)) throw err;
|
||||
refused();
|
||||
await commit();
|
||||
}
|
||||
}
|
||||
openEdit = null;
|
||||
log('play', `edit ${id} committed${notSent ? ' with changesNotSentForReview=true' : ''}`);
|
||||
|
|
|
|||
42
cli/play/old-builds.ts
Normal file
42
cli/play/old-builds.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Builds still active on a testing track below the production build (pitfall 129). Play reviews every active build
|
||||
// with each submission: a policy issue in an old one (a media permission it asked for, "Use alternative system
|
||||
// pickers") blocks "Send changes for review" for the whole app, while the API still reads production as completed.
|
||||
// The Developer API cannot read a bundle's permissions, so each old build is named with the command that replaces it.
|
||||
// Pure.
|
||||
|
||||
export interface TrackLike { track: string; releases?: { name?: string; versionCodes?: string[]; status: string }[] }
|
||||
export interface OldBuild { track: string; versionCodes: number[]; name: string | null; status: string; productionCode: number }
|
||||
|
||||
/** Permissions Play reviews as policy-sensitive (Photo and video permissions, All files access). */
|
||||
export const SENSITIVE_PERMISSIONS = ['READ_MEDIA_IMAGES', 'READ_MEDIA_VIDEO', 'READ_EXTERNAL_STORAGE', 'MANAGE_EXTERNAL_STORAGE', 'WRITE_EXTERNAL_STORAGE'];
|
||||
|
||||
const ACTIVE = new Set(['completed', 'inProgress', 'halted']);
|
||||
const codes = (r: { versionCodes?: string[] }): number[] => (r.versionCodes ?? []).map(Number).filter(Number.isFinite);
|
||||
|
||||
export function oldTrackBuilds(tracks: TrackLike[]): OldBuild[] {
|
||||
const prod = tracks.find(t => t.track === 'production');
|
||||
const prodCodes = (prod?.releases ?? []).filter(r => ACTIVE.has(r.status)).flatMap(codes);
|
||||
if (!prodCodes.length) return [];
|
||||
const productionCode = Math.max(...prodCodes);
|
||||
const out: OldBuild[] = [];
|
||||
for (const t of tracks) {
|
||||
if (t.track === 'production') continue;
|
||||
for (const r of t.releases ?? []) {
|
||||
const cs = codes(r);
|
||||
if (!ACTIVE.has(r.status) || !cs.length || Math.max(...cs) >= productionCode) continue;
|
||||
out.push({ track: t.track, versionCodes: cs, name: r.name ?? null, status: r.status, productionCode });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The track flag of `play release` for a track name (custom closed tracks go by --track). */
|
||||
const trackFlag = (track: string): string => (['internal', 'alpha', 'beta'].includes(track) ? `--${track}` : `--track ${track}`);
|
||||
|
||||
export function oldBuildLines(old: OldBuild[]): string[] {
|
||||
if (!old.length) return [];
|
||||
return [
|
||||
` old builds still active (${old.length}): Play reviews them with every submission; a policy issue in one (e.g. ${SENSITIVE_PERMISSIONS.slice(0, 2).join(' / ')}) blocks Send changes for review. The API cannot read their permissions:`,
|
||||
...old.map(o => ` ${o.track} [${o.versionCodes.join(',')}] ${o.name ?? ''} ${o.status} -> replace: storebound play release --version-code ${o.productionCode} ${trackFlag(o.track)} completed`),
|
||||
];
|
||||
}
|
||||
|
|
@ -25,6 +25,14 @@ export function isReviewRefused(err: unknown): boolean {
|
|||
return reasons.every(r => /badRequest|invalid|failedPrecondition|precondition/i.test(r));
|
||||
}
|
||||
|
||||
/** The opposite 400: with managed publishing off Play refuses changesNotSentForReview=true (live: "Changes are sent for
|
||||
* review automatically"); the commit is redone without it. "cannot be sent ... automatically" is the other refusal. */
|
||||
export function isAutoSendOnly(err: unknown): boolean {
|
||||
const s = String(err instanceof Error ? err.message : err);
|
||||
const m = /-> 400 (.*)$/s.exec(s);
|
||||
return !!m && /are sent for review automatically/i.test(m[1]!);
|
||||
}
|
||||
|
||||
/** The query string with changesNotSentForReview=true added to `base` ("?a=1" or ''). */
|
||||
export function withNotSent(base: string | undefined, on: boolean): string {
|
||||
const parts = [(base ?? '').replace(/^\?/, ''), on ? 'changesNotSentForReview=true' : ''].filter(Boolean);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { playReleases, playReleaseLabel } from '../lib/portfolio';
|
|||
import { playClient } from './client';
|
||||
import { listOneTimeProducts } from './products';
|
||||
import { readyLines, readyToPublish } from '../lib/play-publish';
|
||||
import { oldBuildLines, oldTrackBuilds } from './old-builds';
|
||||
import { clearPending, pendingLine, publishingUrl, readPending } from './review-pending';
|
||||
|
||||
interface Track { track: string; releases?: { name?: string; versionCodes?: string[]; status: string; userFraction?: number }[] }
|
||||
|
|
@ -33,7 +34,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, readyToPublish: readyToPublish({ tracks, publicPage, managedPublishing: config.play?.managedPublishing ?? null }) }); return; }
|
||||
if (ctx.args.has('json')) { printJson({ details, category, tracks, listings, products, publicPage, managedPublishing: config.play?.managedPublishing ?? null, reviewPending, oldBuilds: oldTrackBuilds(tracks), 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(', ') || '-'}`);
|
||||
|
|
@ -44,6 +45,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);
|
||||
for (const l of oldBuildLines(oldTrackBuilds(tracks))) 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(' ')}`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue