// 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)"] [--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 (--yes in // scripts). An UPDATE of a published app (the production track holds a completed / inProgress / halted release) needs // no owner step: `play release --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'; import { createHash } from 'node:crypto'; import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { done, log, warn, printJson } from '../lib/log'; import { aabInfo, assertAabSigned } from '../lib/gradle'; import { playTargetSdkProblem, targetSdkFile } from '../lib/store-minimums'; import { playClient } from './client'; 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']; export interface PlayTrackRelease { versionCodes?: string[]; status?: string; userFraction?: number; name?: string; releaseNotes?: { language: string; text: string }[] } /** The track holds exactly this release already (one release, same codes, status, fraction, name and notes): a PUT * would change nothing. Pure. */ export function sameRelease(cur: PlayTrackRelease[], want: PlayTrackRelease): boolean { if (cur.length !== 1) return false; const c = cur[0]!; const notes = (n?: { language: string; text: string }[]) => JSON.stringify([...(n ?? [])].sort((x, y) => x.language.localeCompare(y.language)).map(x => [x.language, x.text.trim()])); return (c.versionCodes ?? []).map(String).join() === (want.versionCodes ?? []).join() && c.status === want.status && (want.userFraction === undefined || Math.abs((c.userFraction ?? -1) - want.userFraction) < 1e-9) && (want.name === undefined || c.name === want.name) && (want.releaseNotes === undefined || notes(c.releaseNotes) === notes(want.releaseNotes)); } const STATUSES = ['completed', 'draft', 'inProgress', 'halted']; export async function run(ctx: Ctx): Promise { const { config, root } = ctx.loaded; const pkg = need(config.play?.packageName, 'play.packageName'); const a = ctx.args; const aab = a._.find(x => x.endsWith('.aab')); const fixed = a.get('version-code'); if (!aab && !fixed) throw new Error('usage: play release | --version-code N [--internal completed] [--production draft] [--name text] [--not-sent-for-review] [--dry]'); const tracks = TRACKS.filter(t => a.has(t)).map(t => ({ track: t, status: a.get(t) === 'true' ? 'draft' : a.get(t)! })); if (!tracks.length) tracks.push({ track: 'internal', status: 'completed' }); 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). // The kind is read inside the release's own edit (no extra edit), before anything is uploaded. const seen: { kind?: ReturnType } = {}; let refused = false; if (live && !a.has('dry') && !(await confirm(ctx, `Release to PRODUCTION as "${live.status}" (public once Google approves)?`))) return; if (aab) { assertAabSigned(aab); const sdkWhy = playTargetSdkProblem(aabInfo(aab).targetSdk, { where: targetSdkFile(root) }); 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); let released = fixed ?? ''; let nothing = false; try { await play.edit(async (id, ctl) => { if (gated) { const prod = (await play.call<{ releases?: PlayTrackRelease[] }>('GET', `${play.API}/edits/${id}/tracks/production`).catch(() => ({ releases: [] }))).releases ?? []; const kind = (seen.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') })) { refused = true; ctl.unchanged(); return; } } let code = fixed ?? ''; let uploaded = false; // a dry run never uploads: the bundle would go into an edit that is thrown away (14 MB for nothing, and a long // upload can outlive the edit: "This edit has expired"); its tracks are shown, not PUT (Play refuses a track // with a versionCode the edit does not have) let planOnly = false; if (aab) { // a versionCode Play already has (an earlier run uploaded it) is reused, never uploaded twice const info = aabInfo(aab); if ((await usedVersionCodes(play, id)).includes(info.versionCode)) { code = String(info.versionCode); log('play', `versionCode ${code} is on Play already: not uploaded again`); } else if (a.has('dry')) { code = String(info.versionCode); planOnly = true; log('play', `dry run: would upload ${aab} (${(statSync(aab).size / 1048576).toFixed(1)} MB, versionCode ${code}); not uploaded`); } else { shipPhase('upload', `uploading ${aab}`, 0); log('play', `uploading ${aab} (${(statSync(aab).size / 1048576).toFixed(1)} MB), this can take minutes`); const b = await play.call<{ versionCode: number }>('POST', `${play.UPLOAD}/edits/${id}/bundles?uploadType=media`, new Uint8Array(readFileSync(aab)), { 'Content-Type': 'application/octet-stream' }); code = String(b.versionCode); uploaded = true; shipPhase('upload', `uploaded versionCode ${code}`, 100); log('play', `uploaded versionCode ${code}`); } } released = code; let changed = 0; for (const t of tracks) { const release: PlayTrackRelease = { versionCodes: [code], status: t.status }; if (notes.length) release.releaseNotes = notes; if (a.get('name')) release.name = a.get('name'); if (t.status === 'inProgress') release.userFraction = Number(fraction ?? 0.1); const cur = (await play.call<{ releases?: PlayTrackRelease[] }>('GET', `${play.API}/edits/${id}/tracks/${t.track}`).catch(() => ({ releases: [] }))).releases ?? []; if (sameRelease(cur, release)) { log('play', `${t.track}: already ${t.status} ${code}, unchanged`); continue; } if (planOnly) { log('play', `dry run: ${t.track} <- ${code} (${t.status}, notes in ${notes.length} languages)`); changed++; continue; } await play.call('PUT', `${play.API}/edits/${id}/tracks/${t.track}`, { track: t.track, releases: [release] }); changed++; log('play', `${t.track} <- ${code} (${t.status}, notes in ${notes.length} languages)`); } if (!changed && !uploaded) { nothing = true; ctl.unchanged(); } else shipPhase('commit', `${changed} track(s) changed`); }, { dry: a.has('dry'), retries: aab ? 0 : 3, notSentForReview: a.has('not-sent-for-review'), // the staged rollout share is part of the plan (it was applied but not shown in the dry run) summary: `release ${a.get('name') ?? (aab ?? `versionCode ${fixed}`)}: ${tracks.map(t => `${t.track} ${t.status}${t.status === 'inProgress' ? ` ${Math.round(Number(fraction ?? 0.1) * 100)}%` : ''}`).join(', ')}` }); } catch (e) { if (/draft app|status draft/i.test(String(e))) throw new Error(`${(e as Error).message}\n\nThis app has never been published: the API may only create DRAFT releases. Re-run with --production draft, then send it from the Console (skill play-first-release).`); throw e; } if (refused) return; // the ship-step contract: every track holds this release already (and no upload): nothing to do, exit 7 if (nothing && !a.has('dry')) shipNoop({ build: released || undefined, state: tracks.map(t => t.status).join(',') }); const prod = tracks.find(t => t.track === 'production'); if (prod) { // Play's default: an approved release goes live by itself (managed publishing is a console-only setting) if (config.play?.managedPublishing !== true) warn('play', `managed publishing is ${config.play?.managedPublishing === false ? 'off' : 'off by default (play.managedPublishing not set)'}: the release goes live automatically once Google approves it`); if (prod.status === 'draft') log('play', 'next: `storebound console plan play-production-countries` (the API cannot set countries), then `storebound console plan play-production-release` (its final "Send changes for review" is the owner\'s)'); } // 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'), ...(seen.kind ? { releaseKind: seen.kind.kind } : {}) }); done('play', 'release done'); }