// Build a signed release Android App Bundle. // storebound play build [--version-code N] [--version-name 1.2.0] [--flavor prod] [--dir android] [--module app] // [--out build/android] [--no-prebuild] [--allow-stale-web] [--dry] [--json] // First the prebuild (build.prebuild / play.prebuild: e.g. the web build + npx cap sync; once per flow run), then a // Capacitor project's copied web assets (android/app/src/main/assets/public) must be newer than the source. // Finds the Gradle project (android/ of Capacitor, React Native and Flutter apps, the root of a native one, a Unity // export with launcher/ + unityLibrary/), takes the next free versionCode from Google Play (play next-version-code) // unless --version-code is given, runs `gradlew :app:bundleRelease` (Flutter: flutter build appbundle) and copies the // AAB to /-.aab, checked by reading its manifest. Signing: the upload key from env or // .storebound/credentials.json (ANDROID_KEYSTORE, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD) // goes to Gradle as injected signing properties through the environment, never on the command line; without it the // project's own signingConfig signs. Local only: Play is only read (version codes). import { copyFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { join, relative } from 'node:path'; import { androidSigning } from '../lib/credentials'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; import { done, log, printJson, warn } from '../lib/log'; import { pump } from '../lib/runner'; import { aabInfo, findBundles, gradleArgs, pickGradle, signingEnv } from '../lib/gradle'; import { nextVersionCode } from '../lib/release'; import { capacitorProject, webStep } from '../lib/web-assets'; import { playClient } from './client'; import { usedVersionCodes } from './next-version-code'; const TAG = 'build'; 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 cfg = config.play?.build ?? {}; const flavor = a.get('flavor') ?? cfg.flavor; const dir = a.get('dir') ?? cfg.dir; // Capacitor in a monorepo (apps/native/android): play.build.dir / --dir wins, else the one Capacitor project found const cap = capacitorProject(root, dir); const sub = !dir && cap?.dir ? cap.dir : ''; const t = pickGradle(sub ? join(root, sub) : root, { dir, module: a.get('module') ?? cfg.module, flavor }); if (sub) t.notes.unshift(`Capacitor project: ${sub} (${sub}/${cap!.config}; the only one found; set play.build.dir to use another)`); const dry = a.has('dry'); const json = a.has('json'); const out = resolve(a.get('out') ?? 'build/android'); let code = a.has('version-code') ? a.num('version-code', 0) : 0; if (a.has('version-code') && !(Number.isInteger(code) && code > 0 && code <= 2_100_000_000)) throw new Error('--version-code: 1-2100000000'); if (!code) { try { const play = await playClient(root, pkg); code = await play.read(async id => nextVersionCode(await usedVersionCodes(play, id))); log(TAG, `next free versionCode on Google Play: ${code}`); } catch (e) { if (!dry) throw new Error(`${(e as Error).message}\n(pass --version-code N to build without reading Google Play)`); warn(TAG, `Google Play not readable (${(e as Error).message.split('\n')[0]?.slice(0, 120)}): dry run continues without a versionCode`); } } const versionName = a.get('version-name'); const signing = androidSigning(root); const hasWrapper = existsSync(join(t.dir, 'gradlew')); const argv = gradleArgs(t, { versionCode: code || undefined, versionName, flavor, hasWrapper }); const cwd = t.framework === 'flutter' ? root : t.dir; const plan = { framework: t.framework, dir: relative(root, t.dir) || '.', module: t.module, task: t.framework === 'flutter' ? 'flutter build appbundle' : t.task, versionCode: code || null, versionName: versionName ?? null, signing: signing ? `upload key from ${process.env.ANDROID_KEYSTORE ? 'env' : '.storebound/credentials.json'} (alias ${signing.keyAlias})` : 'the project\'s own signingConfig', command: argv.join(' '), out: relative(root, out) || '.', notes: t.notes }; const webOpts = { config, root, cap, store: 'play' as const, tag: TAG, noPrebuild: a.has('no-prebuild'), allowStale: a.has('allow-stale-web') }; if (dry) { const web = await webStep({ ...webOpts, dry: true, quiet: json }); if (json) { printJson({ ...plan, web }); return; } log(TAG, `${t.framework}: ${plan.dir} ${plan.task}, versionCode ${code || '?'}${versionName ? `, versionName ${versionName}` : ''}`); log(TAG, `signing: ${plan.signing}`); for (const n of t.notes) log(TAG, n); log(TAG, `would run (in ${relative(root, cwd) || '.'}): ${plan.command}`); done(TAG, 'build planned (dry run)'); return; } const web = await webStep({ ...webOpts, dry: false }); if (signing && !existsSync(signing.storeFile)) throw new Error('ANDROID_KEYSTORE: the keystore file does not exist'); if (!signing) warn(TAG, 'no ANDROID_KEYSTORE: the project\'s own release signingConfig must sign, else Play refuses the bundle'); for (const n of t.notes) log(TAG, n); log(TAG, `$ ${plan.command}`); const started = Date.now(); mkdirSync(out, { recursive: true }); const p = Bun.spawn(argv, { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, ...signingEnv(signing) } }); const lines: string[] = []; const each = (line: string): void => { lines.push(line); if (/^(> Task .*bundle|BUILD (SUCCESSFUL|FAILED)|FAILURE:|\* What went wrong|e: |error:|✓ Built)/.test(line)) log(TAG, line.trim().slice(0, 300)); }; await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]); const exit = await p.exited; const logFile = join(out, 'gradle.log'); writeFileSync(logFile, lines.join('\n')); if (exit !== 0) throw new Error(`build failed (exit ${exit}); last lines:\n${lines.slice(-20).join('\n')}\nfull log: ${relative(root, logFile)}`); const aab = findBundles([join(t.dir, t.module, 'build', 'outputs', 'bundle'), join(root, 'build', 'app', 'outputs', 'bundle')], started - 1000)[0]; if (!aab) throw new Error(`build finished but no new .aab under ${relative(root, t.dir)}/${t.module}/build/outputs/bundle`); const info = aabInfo(aab); if (info.packageName !== pkg) warn(TAG, `the AAB's package is ${info.packageName}, the config says ${pkg}`); if (code && info.versionCode !== code) warn(TAG, `the AAB has versionCode ${info.versionCode}, not ${code}: the build file sets it itself; read -PversionCode there or pass that number to play upload knowingly`); const dest = join(out, `${info.packageName}-${info.versionCode}.aab`); copyFileSync(aab, dest); const result = { aab: dest, packageName: info.packageName, versionCode: info.versionCode, versionName: info.versionName ?? null, web: web.bundle }; if (json) printJson(result); done(TAG, `${relative(root, dest)}: ${info.packageName} ${info.versionName ?? ''} (${info.versionCode}). Next: storebound play upload ${relative(root, dest)} --track internal --dry`); }