104 lines
6.9 KiB
TypeScript
104 lines
6.9 KiB
TypeScript
// 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 --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.
|
|
// 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 type { Ctx } from '../lib/ctx';
|
|
import { confirm, need } from '../lib/ctx';
|
|
import { done, log, warn } from '../lib/log';
|
|
import { aabInfo } 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';
|
|
|
|
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<void> {
|
|
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 <app.aab> | --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 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)' : ''}`);
|
|
if (live && !a.has('dry') && !(await confirm(ctx, `Release to PRODUCTION as "${live.status}" (public once Google approves)?`))) return;
|
|
|
|
if (aab) {
|
|
const sdkWhy = playTargetSdkProblem(aabInfo(aab).targetSdk, { where: targetSdkFile(root) });
|
|
if (sdkWhy) throw new Error(`${aab}: not released, ${sdkWhy}`);
|
|
}
|
|
const play = await playClient(root, pkg);
|
|
try {
|
|
await play.edit(async (id, ctl) => {
|
|
let code = fixed ?? '';
|
|
let uploaded = 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 {
|
|
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;
|
|
log('play', `uploaded versionCode ${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; }
|
|
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) ctl.unchanged();
|
|
}, { dry: a.has('dry'), retries: aab ? 0 : 3, notSentForReview: a.has('not-sent-for-review'),
|
|
summary: `release ${a.get('name') ?? (aab ?? `versionCode ${fixed}`)}: ${tracks.map(t => `${t.track} ${t.status}`).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;
|
|
}
|
|
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)');
|
|
}
|
|
done('play', 'release done');
|
|
}
|