Updates are agent steps: --yes ships an update of a live app (Play production, App Store submit / release) without an owner gate; the first release stays the owner's (exit 3 OWNER_STEP, --first-release); apple release is a ship step

This commit is contained in:
CRTkafa 2026-10-26 18:09:50 +07:00
parent c247b7e54a
commit ae9d26d3f6
28 changed files with 562 additions and 44 deletions

View file

@ -1,10 +1,14 @@
// Upload an AAB (or reuse an uploaded versionCode) and put it on one or more tracks in one edit, with the "What's new"
// of every listing language as release notes.
// storebound play release app-release.aab [--internal completed] [--production draft] [--name "1.0.0 (3)"] [--dry]
// storebound play release app-release.aab [--internal completed] [--production draft] [--name "1.0.0 (3)"] [--first-release] [--dry]
// storebound play release --version-code 3 --production draft
// storebound play release app.aab --production completed --not-sent-for-review (commit with changesNotSentForReview=true)
// Tracks: internal, alpha (closed), beta (open), production; status completed | draft | inProgress | halted.
// Without a track flag: internal, completed. A production release that is not a draft asks for confirmation.
// Without a track flag: internal, completed. A production release that is not a draft asks for confirmation (--yes in
// scripts). An UPDATE of a published app (the production track holds a completed / inProgress / halted release) needs
// no owner step: `play release <aab> --production completed --yes` runs in a pipeline (cli/lib/release-kind.ts). The
// first-ever production release with --yes is exit 3 OWNER_STEP unless the owner approved it (--first-release, or
// bypass mode).
// Draft app (never published): Play only accepts status "draft" on any track; the first production release is sent
// from the Console (see the play-first-release skill).
import { readFileSync, statSync } from 'node:fs';
@ -19,6 +23,7 @@ import { usedVersionCodes } from './next-version-code';
import { readPlayCopy, refusePlayNotes } from './listing';
import { shipNoop, shipPhase, shipReport } from '../lib/ship-step';
import { consoleUrl, playTrackPage } from '../lib/console-links';
import { enforceFirstRelease, playReleaseKind } from '../lib/release-kind';
const TRACKS = ['internal', 'alpha', 'beta', 'production'];
@ -49,8 +54,20 @@ export async function run(ctx: Ctx): Promise<void> {
for (const t of tracks) if (!STATUSES.includes(t.status)) throw new Error(`--${t.track}: status must be one of ${STATUSES.join(', ')}`);
const fraction = a.get('fraction');
const live = tracks.find(t => t.track === 'production' && t.status !== 'draft');
const gated = tracks.find(t => t.track === 'production' && (t.status === 'completed' || t.status === 'inProgress'));
const notes = readPlayCopy(ctx).filter(c => c.whatsNew).map(c => ({ language: c.lang, text: c.whatsNew }));
refusePlayNotes(ctx, notes, `play release notes${a.has('dry') ? ' (dry run)' : ''}`);
// update of a published app: no owner step; the first production release: the owner's (exit 3 OWNER_STEP with --yes)
let early: Awaited<ReturnType<typeof playClient>> | undefined;
const client = async (): Promise<Awaited<ReturnType<typeof playClient>>> => (early ??= await playClient(root, pkg));
let kind: ReturnType<typeof playReleaseKind> | null = null;
if (gated) {
const p0 = await client();
const prod = await p0.read(async id => (await p0.call<{ releases?: PlayTrackRelease[] }>('GET', `${p0.API}/edits/${id}/tracks/production`).catch(() => ({ releases: [] }))).releases ?? []);
kind = playReleaseKind(prod);
if (a.has('dry')) log('play', `${kind.kind === 'update' ? 'update of a published app' : 'FIRST production release (the owner approves it once: --first-release)'}: ${kind.why}`);
else if (!enforceFirstRelease(ctx, kind, { store: 'play', what: 'the first production release', consoleUrl: consoleUrl('play.publishing') })) return;
}
if (live && !a.has('dry') && !(await confirm(ctx, `Release to PRODUCTION as "${live.status}" (public once Google approves)?`))) return;
if (aab) {
@ -59,7 +76,7 @@ export async function run(ctx: Ctx): Promise<void> {
if (sdkWhy) throw new Error(`${aab}: not released, ${sdkWhy}`);
}
shipReport({ track: tracks.map(t => t.track).join(','), url: consoleUrl(playTrackPage(tracks.length === 1 ? tracks[0]!.track : undefined)) });
const play = await playClient(root, pkg);
const play = await client();
let released = fixed ?? '';
let nothing = false;
try {
@ -116,6 +133,6 @@ export async function run(ctx: Ctx): Promise<void> {
}
// machine-readable result for CI scripts (a Jenkins stage reads it instead of calling the Play API itself)
if (a.has('json')) printJson({ versionCode: released ? Number(released) : null, sha256: aab ? createHash('sha256').update(readFileSync(aab)).digest('hex') : null, name: a.get('name') ?? null,
tracks: tracks.map(t => ({ track: t.track, status: t.status, userFraction: t.status === 'inProgress' ? Number(fraction ?? 0.1) : null })), dry: a.has('dry') });
tracks: tracks.map(t => ({ track: t.track, status: t.status, userFraction: t.status === 'inProgress' ? Number(fraction ?? 0.1) : null })), dry: a.has('dry'), ...(kind ? { releaseKind: kind.kind } : {}) });
done('play', 'release done');
}

View file

@ -1,12 +1,14 @@
// Upload an Android App Bundle to a Google Play testing track.
// storebound play upload [<app.aab>] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--pre-check "<cmd with {file}>"] [--evidence <path.json>] [--dry] [--yes] [--json]
// Upload an Android App Bundle to a Google Play track (testing tracks, or production for an UPDATE of a published app).
// storebound play upload [<app.aab>] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--first-release] [--pre-check "<cmd with {file}>"] [--evidence <path.json>] [--dry] [--yes] [--json]
// Default AAB: the newest in build/android (storebound play build), or with --version-code N the one with that
// versionCode. Package and versionCode are read from the bundle; the upload is refused when Play already has that
// versionCode (exit 1), and under the ship-step contract (--json / --result-file, docs/ship-step.md) it is exit 7
// "nothing to do" with the track and state that are there: never a second upload. Release notes: the "What's new" of
// every listing language. --dry is read-only (one throwaway edit: version codes and the track are read, the edit is
// deleted). A real run asks first (--yes in scripts). --track production sends the release to Google's review (a
// pipeline's update step); --status draft keeps it for the Console.
// pipeline's update step): no owner gate when the production track already holds a published release (an update of
// a live app, cli/lib/release-kind.ts); a first-ever production release with --yes is exit 3 OWNER_STEP unless the owner
// approved it (--first-release, or bypass mode). --status draft keeps the release for the Console.
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import type { Ctx } from '../lib/ctx';
@ -21,6 +23,7 @@ import { usedVersionCodes } from './next-version-code';
import { runPreCheck } from '../lib/pre-check';
import { shipNoop, shipPhase, shipReport, shipRun, shipWarn } from '../lib/ship-step';
import { consoleUrl, playTrackPage } from '../lib/console-links';
import { enforceFirstRelease, playReleaseKind } from '../lib/release-kind';
const TAG = 'upload';
export const UPLOAD_TRACKS = ['internal', 'alpha', 'beta', 'production'] as const;
@ -83,11 +86,15 @@ export async function run(ctx: Ctx): Promise<void> {
if (shipRun()) { done(TAG, `versionCode ${info.versionCode} is on Google Play already: nothing uploaded`); return; }
}
if (used.includes(info.versionCode)) throw new Error(`versionCode ${info.versionCode} is already on Google Play: rebuild with --version-code ${nextVersionCode(used)} (storebound play build)`);
const summary = { aab: relative(root, aab), packageName: info.packageName, versionCode: info.versionCode, versionName: info.versionName ?? null, sizeMB: Math.round(size / 104857.6) / 10,
// production: update of a published app (no owner gate) or the first release (the owner's step)
const kind = track === 'production' && status !== 'draft' ? playReleaseKind(current) : null;
if (kind && !dry && !enforceFirstRelease(ctx, kind, { store: 'play', what: 'the first production release', consoleUrl: consoleUrl('play.publishing') })) return;
const summary = { ...(kind ? { releaseKind: kind.kind } : {}), aab: relative(root, aab), packageName: info.packageName, versionCode: info.versionCode, versionName: info.versionName ?? null, sizeMB: Math.round(size / 104857.6) / 10,
track, status, notes: notes.map(n => n.language), replaces: current.map(r => `${r.status} ${(r.versionCodes ?? []).join(',')}`), ...(pre ? { preCheck: pre } : {}) };
if (dry) {
if (json) { printJson(summary); return; }
log(TAG, `${summary.aab}: ${info.packageName} ${info.versionName ?? ''} (${info.versionCode}), ${summary.sizeMB} MB, versionCode free`);
if (kind) log(TAG, `${kind.kind === 'update' ? 'update of a published app' : 'FIRST production release (the owner approves it once: --first-release)'}: ${kind.why}`);
log(TAG, `would upload it and set track ${track} to ${status} ${info.versionCode}${notes.length ? `, notes in ${notes.length} language(s)` : ', no release notes'}${current.length ? ` (now on ${track}: ${summary.replaces.join('; ')})` : ''}`);
done(TAG, 'upload planned (dry run, read-only)');
return;