Signed AAB guardrails, injected signing args, IPA version train check, Play notes lint at build
This commit is contained in:
parent
707db2ebc6
commit
7960063e0e
12 changed files with 181 additions and 14 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue