// Upload an Android App Bundle to a Google Play testing track. // storebound play upload [] [--track internal|alpha|beta] [--status completed|draft] [--name text] [--not-sent-for-review] [--dry] [--yes] [--json] // Default AAB: the newest in build/android (storebound play build). Package and versionCode are read from the bundle; // the upload is refused when Play already has that versionCode. 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). Production is not offered here: `storebound play release --production // draft` (and the play-first-release / release-management skills) keep that the owner's step. 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 } from '../lib/gradle'; import { done, log, printJson } from '../lib/log'; import { nextVersionCode } from '../lib/release'; import { playClient } from './client'; import { readPlayCopy } from './listing'; import { usedVersionCodes } from './next-version-code'; const TAG = 'upload'; export const UPLOAD_TRACKS = ['internal', 'alpha', 'beta'] as const; export async function run(ctx: Ctx): Promise { 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 (track === 'production') throw new Error('play upload is for testing tracks; production goes through storebound play release --production draft (the owner sends it)'); 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'); 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 (!found[0]) throw new Error('usage: storebound play upload --track internal (nothing in build/android; storebound play build makes one)'); aab = found[0]; } const info = aabInfo(aab); if (info.packageName !== pkg) throw new Error(`${relative(root, aab)} is ${info.packageName}, the config says ${pkg}`); 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 })); 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)) { if (json) { printJson({ aab: relative(root, aab), packageName: info.packageName, versionCode: info.versionCode, 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)) 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, track, status, notes: notes.map(n => n.language), replaces: current.map(r => `${r.status} ${(r.versionCodes ?? []).join(',')}`) }; if (dry) { if (json) { printJson(summary); return; } log(TAG, `${summary.aab}: ${info.packageName} ${info.versionName ?? ''} (${info.versionCode}), ${summary.sizeMB} MB, versionCode free`); 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})? Testers of that track get it.`))) return; try { await play.edit(async id => { log(TAG, `uploading ${summary.sizeMB} 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' }); log(TAG, `uploaded versionCode ${b.versionCode}`); const release: Record = { 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})`); }, { 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}`); }