Signed AAB guardrails, injected signing args, IPA version train check, Play notes lint at build

This commit is contained in:
CRTkafa 2026-10-23 08:49:43 +08:00
parent 707db2ebc6
commit 7960063e0e
12 changed files with 181 additions and 14 deletions

View file

@ -8,7 +8,8 @@
// 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 through the environment, never on the command line; without it the
// goes to Gradle as injected signing properties (-Pandroid.injected.signing.* and the ORG_GRADLE_PROJECT_ environment; the
// passwords are masked in every printed command); 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
@ -20,16 +21,25 @@ 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, findBundles, gradleArgs, pickGradle, signingEnv, versionSourceIssues, versionSourceMessage } from '../lib/gradle';
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';
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;
@ -60,7 +70,7 @@ export async function run(ctx: Ctx): Promise<void> {
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 });
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
@ -70,7 +80,7 @@ export async function run(ctx: Ctx): Promise<void> {
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: argv.join(' '), out: relative(root, out) || '.', notes: t.notes, versionWarnings };
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 });
@ -85,6 +95,7 @@ export async function run(ctx: Ctx): Promise<void> {
return;
}
if (!sdk.path) throw new Error(androidSdkMissing(sdk));
notesGuard(ctx);
log(TAG, `Android SDK: ${sdk.path} (from ${sdk.source})`);
const web = await webStep({ ...webOpts, dry: false });
if (signing && !existsSync(signing.storeFile)) throw new Error('ANDROID_KEYSTORE: the keystore file does not exist');
@ -108,6 +119,8 @@ export async function run(ctx: Ctx): Promise<void> {
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);
// Play refuses an unsigned bundle (403) only at upload: stop here, naming the fix
assertAabSigned(aab, relative(root, aab));
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) {