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

@ -5,6 +5,7 @@
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import type { AndroidSigning } from './credentials';
import * as rt from './rt';
import { zipEntries, zipRead } from './zip';
export interface PlayBuildConfig {
@ -57,13 +58,28 @@ export function pickGradle(root: string, cfg: PlayBuildConfig = {}): GradleTarge
* override defaultConfig for bundleRelease: checked with AGP 8.13 (2026-10-12), `-Pandroid.injected.version.code=42`
* on a build file with `versionCode 1` gives an AAB with versionCode 1; seen live the same day with a Capacitor app.
* They stay on the command line for older plugins / IDE parity only; the AAB check after the build is the stop. */
export function gradleArgs(t: GradleTarget, o: { versionCode?: number; versionName?: string; flavor?: string; hasWrapper: boolean }, platform: string = process.platform): string[] {
export function gradleArgs(t: GradleTarget, o: { versionCode?: number; versionName?: string; flavor?: string; hasWrapper: boolean; signing?: AndroidSigning | null }, platform: string = process.platform): string[] {
if (t.framework === 'flutter')
return ['flutter', 'build', 'appbundle', '--release', ...(o.flavor ? ['--flavor', o.flavor] : []), ...(o.versionCode ? ['--build-number', String(o.versionCode)] : []), ...(o.versionName ? ['--build-name', o.versionName] : [])];
// Windows: a bare `gradlew.bat` is searched on PATH only ("Executable not found in $PATH"); cmd /c finds it in the cwd
return [...(o.hasWrapper ? (platform === 'win32' ? ['cmd', '/c', 'gradlew.bat'] : ['./gradlew']) : platform === 'win32' ? ['cmd', '/c', 'gradle'] : ['gradle']), t.task, '--no-daemon', '--console=plain',
...(o.versionCode ? [`-Pandroid.injected.version.code=${o.versionCode}`, `-PversionCode=${o.versionCode}`] : []),
...(o.versionName ? [`-Pandroid.injected.version.name=${o.versionName}`, `-PversionName=${o.versionName}`] : [])];
...(o.versionName ? [`-Pandroid.injected.version.name=${o.versionName}`, `-PversionName=${o.versionName}`] : []),
...signingArgs(o.signing ?? null)];
}
/** The upload key as Gradle project properties (-Pandroid.injected.signing.*). A project whose build file signs only
* from a local keystore.properties (absent in CI) gives an unsigned AAB otherwise (Play: 403 "All uploaded bundles
* must be signed"). The passwords are on this argv: print it only through maskArgs. */
export function signingArgs(s: AndroidSigning | null): string[] {
if (!s) return [];
return [`-Pandroid.injected.signing.store.file=${s.storeFile}`, `-Pandroid.injected.signing.store.password=${s.storePassword}`,
`-Pandroid.injected.signing.key.alias=${s.keyAlias}`, `-Pandroid.injected.signing.key.password=${s.keyPassword}`];
}
/** An argv for logs and plans: the signing passwords are replaced by ***. Pure. */
export function maskArgs(argv: readonly string[]): string[] {
return argv.map(x => x.replace(/^(-Pandroid\.injected\.signing\.(?:store|key)\.password=).*$/, '$1***'));
}
// ---- where the build file takes versionCode / versionName from ----
@ -282,3 +298,39 @@ export function encodeProtoManifest(els: { name: string; attrs: Record<string, s
};
return ld(1, element(0));
}
// ---- is the AAB signed? ----
/** Whether the bundle carries a JAR signature (META-INF/<name>.SF plus a .RSA / .DSA / .EC block). Play refuses an
* unsigned bundle with 403 "All uploaded bundles must be signed". Pure apart from reading the file. */
export function aabSignedEntries(aab: string | Buffer): boolean {
const names = zipEntries(aab).map(e => e.name);
return names.some(n => /^META-INF\/[^/]+\.SF$/i.test(n)) && names.some(n => /^META-INF\/[^/]+\.(RSA|DSA|EC)$/i.test(n));
}
/** The reason an AAB is unsigned (null when signed). `jarsigner` (a JDK) verifies the signature when `verify` is given
* and returns its output (null: not available); without it only the META-INF entries are checked. */
export function aabUnsignedReason(aab: string | Buffer, verify?: (path: string) => { ok: boolean; out: string } | null, path?: string): string | null {
if (!aabSignedEntries(aab)) return 'it has no META-INF/*.SF + *.RSA|DSA|EC signature files';
if (verify && path) {
const r = verify(path);
if (r && !r.ok) return `jarsigner -verify: ${r.out.trim().split(/\r?\n/).filter(Boolean).slice(0, 2).join(' ')}`;
}
return null;
}
/** Throws when the AAB file is not signed, before any upload. `jarsigner` runs when a JDK is on PATH. */
export function assertAabSigned(path: string, rel = path, verify: (p: string) => { ok: boolean; out: string } | null = jarsignerVerify): void {
const why = aabUnsignedReason(path, verify, path);
if (why) throw new Error(`${rel} is not signed (${why}): Google Play would refuse it (403 "All uploaded bundles must be signed"). Sign it with the upload key: set ANDROID_KEYSTORE / ANDROID_KEYSTORE_PASSWORD / ANDROID_KEY_ALIAS / ANDROID_KEY_PASSWORD for storebound play build (they are passed to Gradle as -Pandroid.injected.signing.*), or give the project's release signingConfig a keystore in CI. Nothing was uploaded.`);
}
function jarsignerVerify(path: string): { ok: boolean; out: string } | null {
if (!rt.which('jarsigner')) return null;
const r = rt.spawnSync(['jarsigner', '-verify', path], { timeout: 120_000 });
const out = `${r.stdout.toString()}${r.stderr.toString()}`;
// jarsigner exits 0 for an unsigned jar too and prints "jar is unsigned"; only a signature verdict counts, a zip it
// cannot read (bad CRC, ...) is inconclusive and left to the META-INF entry check
return { ok: /jar verified/i.test(out) || !/jar is unsigned|did not verify|SecurityException|invalid SHA/i.test(out), out };
}

View file

@ -173,6 +173,14 @@ export function ipaInfo(ipa: string | Buffer): IpaInfo {
xcode: str('DTXcode'), sdkName: str('DTSDKName'), platformVersion: str('DTPlatformVersion') };
}
/** The refusal text when the IPA's CFBundleShortVersionString is not the version being prepared (an Xcode project
* that ignores the version storebound or the release script intends exports MARKETING_VERSION 1.0), else null.
* `wanted` is the --version of apple build or the editable App Store version; `source` names where it comes from. Pure. */
export function ipaVersionProblem(ipaVersion: string, wanted: string | null | undefined, source: string): string | null {
if (!wanted || ipaVersion === wanted) return null;
return `the IPA has version ${ipaVersion} (CFBundleShortVersionString), but ${source} is ${wanted}: the Xcode project sets MARKETING_VERSION itself, so the build lands in the wrong version train. Make the project read the intended version (xcodebuild MARKETING_VERSION=${wanted}, or set it in the project / Info.plist), build again, or pass --allow-version-mismatch to go on anyway.`;
}
/** End states of an App Store Connect build upload (BuildUploadState); processing is polled by apple testflight wait. */
export const UPLOAD_DONE = new Set(['COMPLETE', 'FAILED']);