147 lines
11 KiB
TypeScript
147 lines
11 KiB
TypeScript
// 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 <out>/<package>-<versionCode>.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 (store file and alias as -P arguments, the passwords only through
|
|
// ORG_GRADLE_PROJECT_* in the child environment, never argv); without it the
|
|
// project's own signingConfig signs. Local only: Play is only read (version codes).
|
|
// Before Gradle runs, the app module's build file must read -PversionCode / -PversionName in defaultConfig (a literal
|
|
// or an env var keeps the AAB at that value; android.injected.version.* does not override it for bundleRelease): a
|
|
// warning with the exact line to use. After the build, an AAB whose versionCode is not the planned one is a hard stop.
|
|
import { chmodSync, copyFileSync, existsSync, mkdirSync, statSync, 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, appBuildFile, assertAabSigned, maskArgs, findBundles, gradleArgs, pickGradle, signingEnv, versionSourceIssues, versionSourceMessage } from '../lib/gradle';
|
|
import { nextVersionCode } from '../lib/release';
|
|
import { capacitorProject, webStep } from '../lib/web-assets';
|
|
import { androidSdk, androidSdkEnv, androidSdkMissing } from '../lib/tools';
|
|
import { playClient } from './client';
|
|
import { readPlayCopy, refusePlayNotes } from './listing';
|
|
import { usedVersionCodes } from './next-version-code';
|
|
import * as rt from '../lib/rt';
|
|
import { shipPhase } from '../lib/ship-step';
|
|
|
|
const TAG = 'build';
|
|
|
|
/** Release notes that name iOS / App Store are refused at release time: refuse them before the long build. A missing
|
|
* listing file is not a build problem. */
|
|
export function notesGuard(ctx: Ctx): void {
|
|
let notes: { language: string; text: string }[];
|
|
try { notes = readPlayCopy(ctx).filter(c => c.whatsNew).map(c => ({ language: c.lang, text: c.whatsNew })); } catch { return; }
|
|
refusePlayNotes(ctx, notes, 'play build (release notes of the listing)');
|
|
}
|
|
|
|
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 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, process.platform === 'win32' ? 'gradlew.bat' : 'gradlew'));
|
|
const argv = gradleArgs(t, { versionCode: code || undefined, versionName, flavor, hasWrapper, signing });
|
|
const cwd = t.framework === 'flutter' ? root : t.dir;
|
|
// the SDK as Android Studio finds it (ANDROID_HOME is often unset in non-interactive shells and local.properties is
|
|
// gitignored): handed to Gradle through the environment, local.properties is never written
|
|
const sdk = androidSdk([t.dir]);
|
|
// the build file must read -PversionCode / -PversionName (Flutter passes --build-number / --build-name instead)
|
|
const buildFile = t.framework === 'flutter' ? null : appBuildFile(t);
|
|
const versionWarnings = buildFile ? versionSourceIssues(buildFile).map(i => versionSourceMessage(i, relative(root, buildFile), t.framework)) : [];
|
|
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',
|
|
androidSdk: sdk.path ? `${sdk.path} (${sdk.source})` : null, command: maskArgs(argv).join(' '), out: relative(root, out) || '.', notes: t.notes, versionWarnings };
|
|
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}`);
|
|
if (sdk.path) log(TAG, `Android SDK: ${sdk.path} (from ${sdk.source})`); else warn(TAG, androidSdkMissing(sdk));
|
|
for (const n of t.notes) log(TAG, n);
|
|
for (const w of versionWarnings) warn(TAG, w);
|
|
log(TAG, `would run (in ${relative(root, cwd) || '.'}): ${plan.command}`);
|
|
done(TAG, 'build planned (dry run)');
|
|
return;
|
|
}
|
|
if (!sdk.path) throw new Error(androidSdkMissing(sdk));
|
|
notesGuard(ctx);
|
|
log(TAG, `Android SDK: ${sdk.path} (from ${sdk.source})`);
|
|
shipPhase('prebuild', 'web build / prebuild');
|
|
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);
|
|
for (const w of versionWarnings) warn(TAG, w);
|
|
log(TAG, `$ ${plan.command}`);
|
|
shipPhase('build', `${plan.task}, versionCode ${code || '?'}`);
|
|
const started = Date.now();
|
|
mkdirSync(out, { recursive: true });
|
|
// a gradlew committed from Windows has no exec bit: macOS / Linux then say "Executable not found: ./gradlew"
|
|
if (hasWrapper && process.platform !== 'win32') {
|
|
const w = join(t.dir, 'gradlew');
|
|
if ((statSync(w).mode & 0o111) === 0) { chmodSync(w, statSync(w).mode | 0o755); log(TAG, `${relative(root, w)} had no exec bit (committed from Windows?): made it executable; commit it with git update-index --chmod=+x`); }
|
|
}
|
|
const p = rt.spawn(argv, { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, ...androidSdkEnv(sdk), ...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`);
|
|
shipPhase('sign', 'checking the bundle signature');
|
|
const info = aabInfo(aab);
|
|
// Play refuses an unsigned bundle (403) only at upload: stop here, naming the fix
|
|
assertAabSigned(aab, relative(root, aab));
|
|
shipPhase('verify', `versionCode ${info.versionCode}, ${info.packageName}`);
|
|
if (info.packageName !== pkg) warn(TAG, `the AAB's package is ${info.packageName}, the config says ${pkg}`);
|
|
// the hard stop: an AAB with another versionCode than planned is never copied to the output folder
|
|
if (code && info.versionCode !== code) {
|
|
const fix = buildFile ? versionSourceIssues(buildFile).filter(i => i.field === 'versionCode').map(i => ` Use in defaultConfig of ${relative(root, buildFile)}: ${i.fix}`).join('') : '';
|
|
throw new Error(`the AAB has versionCode ${info.versionCode}, not ${code}: the build file sets it itself and ignores -PversionCode (android.injected.version.code does not override it).${fix || ' Read -PversionCode in defaultConfig.'} Then build again. (Built: ${relative(root, aab)}, not copied.)`);
|
|
}
|
|
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 };
|
|
writeFileSync(join(out, 'latest.json'), `${JSON.stringify(result, null, 2)}
|
|
`);
|
|
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`);
|
|
}
|