122 lines
10 KiB
TypeScript
122 lines
10 KiB
TypeScript
// 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): 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';
|
|
import { confirm, need } from '../lib/ctx';
|
|
import { aabInfo, assertAabSigned } from '../lib/gradle';
|
|
import { playTargetSdkProblem, targetSdkFile } from '../lib/store-minimums';
|
|
import { done, log, printJson } from '../lib/log';
|
|
import { nextVersionCode } from '../lib/release';
|
|
import { playClient } from './client';
|
|
import { readPlayCopy, refusePlayNotes } from './listing';
|
|
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;
|
|
|
|
export async function run(ctx: Ctx): Promise<void> {
|
|
const { config, root, resolve } = ctx.loaded;
|
|
const a = ctx.args;
|
|
const pkg = need(config.play?.packageName, 'play.packageName');
|
|
const track = a.str('track', 'internal');
|
|
if (!(UPLOAD_TRACKS as readonly string[]).includes(track)) throw new Error(`--track: ${UPLOAD_TRACKS.join(' | ')}`);
|
|
const status = a.str('status', 'completed');
|
|
if (!['completed', 'draft'].includes(status)) throw new Error('--status: completed | draft');
|
|
const wantCode = a.get('version-code');
|
|
if (wantCode !== undefined && !/^\d+$/.test(wantCode)) throw new Error('--version-code: an integer (the versionCode of the AAB to upload)');
|
|
let aab = a._.find(x => x.endsWith('.aab'));
|
|
if (aab) aab = resolve(aab);
|
|
else {
|
|
const dir = resolve('build/android');
|
|
let found: string[] = [];
|
|
try { found = readdirSync(dir).filter(f => f.endsWith('.aab')).map(f => join(dir, f)).sort((x, y) => statSync(y).mtimeMs - statSync(x).mtimeMs); } catch { /* none */ }
|
|
if (wantCode) found = found.filter(f => { try { return aabInfo(f).versionCode === Number(wantCode); } catch { return false; } });
|
|
if (!found[0]) throw new Error(`usage: storebound play upload <app.aab> --track internal (no ${wantCode ? `versionCode ${wantCode} ` : ''}AAB in build/android; storebound play build makes one)`);
|
|
aab = found[0];
|
|
}
|
|
shipPhase('verify', `checking ${relative(root, aab)}`);
|
|
const info = aabInfo(aab);
|
|
if (wantCode && info.versionCode !== Number(wantCode)) throw new Error(`${relative(root, aab)} is versionCode ${info.versionCode}, not ${wantCode} (--version-code)`);
|
|
shipReport({ track, version: info.versionName ?? undefined, build: String(info.versionCode), url: consoleUrl(playTrackPage(track)) });
|
|
if (info.packageName !== pkg) throw new Error(`${relative(root, aab)} is ${info.packageName}, the config says ${pkg}`);
|
|
// Play refuses bundles below its current target API level (cli/lib/store-minimums.ts): stop before any network call
|
|
const sdkWhy = playTargetSdkProblem(info.targetSdk, { where: targetSdkFile(root) });
|
|
if (sdkWhy) throw new Error(`${relative(root, aab)}: not uploaded, ${sdkWhy}`);
|
|
assertAabSigned(aab, relative(root, aab));
|
|
const pre = await runPreCheck({ args: a, config, root, resolve, file: aab, shown: relative(root, aab), tag: TAG });
|
|
const size = statSync(aab).size;
|
|
const dry = a.has('dry');
|
|
const json = a.has('json');
|
|
const notes = readPlayCopy(ctx).filter(c => c.whatsNew).map(c => ({ language: c.lang, text: c.whatsNew }));
|
|
refusePlayNotes(ctx, notes, `play upload release notes${dry ? ' (dry run)' : ''}`);
|
|
const play = await playClient(root, pkg);
|
|
const { used, current } = await play.read(async id => ({
|
|
used: await usedVersionCodes(play, id),
|
|
current: (await play.call<{ releases?: { status: string; versionCodes?: string[] }[] }>('GET', `${play.API}/edits/${id}/tracks/${track}`).catch(() => ({ releases: [] }))).releases ?? [],
|
|
}));
|
|
// an earlier run did it already (the bundle is on Play and on this track): nothing to send again
|
|
if (used.includes(info.versionCode) && current.some(r => (r.versionCodes ?? []).includes(String(info.versionCode)) && r.status === status)) {
|
|
shipNoop({ state: status });
|
|
if (json) { printJson({ aab: relative(root, aab), packageName: info.packageName, versionCode: info.versionCode, versionName: info.versionName ?? null, track, status, uploaded: true, alreadyThere: true }); return; }
|
|
done(TAG, `versionCode ${info.versionCode} is on ${track} (${status}) already: nothing uploaded`);
|
|
return;
|
|
}
|
|
if (used.includes(info.versionCode) && !dry) {
|
|
// the ship-step contract: the versionCode is on Play already, nothing is uploaded again (exit 7 with what is there)
|
|
const on = await play.read(async id => (await play.call<{ tracks?: { track: string; releases?: { status: string; versionCodes?: string[] }[] }[] }>('GET', `${play.API}/edits/${id}/tracks`)).tracks ?? []).catch(() => []);
|
|
const where = on.flatMap(t => (t.releases ?? []).filter(r => (r.versionCodes ?? []).includes(String(info.versionCode))).map(r => ({ track: t.track, status: r.status })));
|
|
const here = where.find(w => w.track === track) ?? where[0];
|
|
if (here) shipNoop({ track: here.track, state: here.status, url: consoleUrl(playTrackPage(here.track)) });
|
|
else shipNoop({ state: 'uploaded' });
|
|
if (!where.some(w => w.track === track)) shipWarn(`versionCode ${info.versionCode} is on Google Play${where.length ? ` (${where.map(w => `${w.track} ${w.status}`).join(', ')})` : ' (in the bundle library, on no track)'} but not on ${track}: storebound play release --version-code ${info.versionCode} --${track} ${status} puts it there`);
|
|
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)`);
|
|
// 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;
|
|
}
|
|
if (!(await confirm(ctx, `Upload ${info.packageName} versionCode ${info.versionCode} and put it on the ${track} track (${status})? ${track === 'production' ? 'It goes to Google review and then to every user.' : 'Testers of that track get it.'}`))) return;
|
|
try {
|
|
await play.edit(async id => {
|
|
log(TAG, `uploading ${summary.sizeMB} MB, this can take minutes`);
|
|
shipPhase('upload', `uploading ${summary.sizeMB} MB`, 0);
|
|
const b = await play.call<{ versionCode: number }>('POST', `${play.UPLOAD}/edits/${id}/bundles?uploadType=media`, new Uint8Array(readFileSync(aab!)), { 'Content-Type': 'application/octet-stream' });
|
|
log(TAG, `uploaded versionCode ${b.versionCode}`);
|
|
shipPhase('upload', `uploaded versionCode ${b.versionCode}`, 100);
|
|
const release: Record<string, unknown> = { versionCodes: [String(b.versionCode)], status, ...(notes.length ? { releaseNotes: notes } : {}), ...(a.get('name') ? { name: a.get('name') } : {}) };
|
|
await play.call('PUT', `${play.API}/edits/${id}/tracks/${track}`, { track, releases: [release] });
|
|
log(TAG, `${track} <- ${b.versionCode} (${status})`);
|
|
shipPhase('commit', `${track} <- ${b.versionCode} (${status})`);
|
|
}, { retries: 0, notSentForReview: ctx.args.has('not-sent-for-review'), summary: `upload versionCode ${info.versionCode} to ${track} ${status}` });
|
|
} catch (e) {
|
|
if (/draft app|status draft/i.test(String(e)) && status !== 'draft')
|
|
throw new Error(`${(e as Error).message}\n\nThis app has never been published: Play only takes draft releases. Re-run with --status draft and roll it out from the Console (skill play-first-release).`);
|
|
throw e;
|
|
}
|
|
if (json) printJson({ ...summary, uploaded: true });
|
|
done(TAG, `versionCode ${info.versionCode} on ${track}`);
|
|
}
|