336 lines
22 KiB
TypeScript
336 lines
22 KiB
TypeScript
// Android release builds: find the Gradle project (native, Capacitor, React Native, Flutter, Unity export), the
|
|
// bundle task, the signing environment (the upload key from env / .storebound, handed to Gradle as injected
|
|
// properties through ORG_GRADLE_PROJECT_* variables, so passwords never appear on a command line), and read
|
|
// package / versionCode / versionName back from the built AAB (its manifest is protocol buffers, aapt2 format).
|
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { join, relative, resolve } from 'node:path';
|
|
import type { AndroidSigning } from './credentials';
|
|
import * as rt from './rt';
|
|
import { zipEntries, zipRead } from './zip';
|
|
|
|
export interface PlayBuildConfig {
|
|
/** Gradle project folder (with gradlew / settings.gradle), relative to the config. Default: android/, else the root. */
|
|
dir?: string;
|
|
/** Application module. Default app (Unity export: launcher). */
|
|
module?: string;
|
|
/** Product flavor, e.g. "prod" -> bundleProdRelease. */
|
|
flavor?: string;
|
|
}
|
|
|
|
export function validatePlayBuild(b: PlayBuildConfig | undefined): string[] {
|
|
if (!b) return [];
|
|
const e: string[] = [];
|
|
if (b.module !== undefined && !/^[A-Za-z0-9_-]+(:[A-Za-z0-9_-]+)*$/.test(b.module)) e.push('play.build.module: a Gradle module name like app');
|
|
if (b.flavor !== undefined && !/^[A-Za-z][A-Za-z0-9]*$/.test(b.flavor)) e.push('play.build.flavor: a product flavor name like prod');
|
|
return e;
|
|
}
|
|
|
|
export type AndroidFramework = 'flutter' | 'capacitor' | 'react-native' | 'unity' | 'native';
|
|
|
|
export interface GradleTarget { framework: AndroidFramework; dir: string; module: string; task: string; notes: string[] }
|
|
|
|
const isGradleDir = (d: string): boolean => ['settings.gradle', 'settings.gradle.kts'].some(f => existsSync(join(d, f)));
|
|
|
|
/** Finds the Gradle project. `root` is the folder of storebound.config.ts. */
|
|
export function pickGradle(root: string, cfg: PlayBuildConfig = {}): GradleTarget {
|
|
const notes: string[] = [];
|
|
const framework: AndroidFramework = existsSync(join(root, 'pubspec.yaml')) ? 'flutter'
|
|
: ['capacitor.config.ts', 'capacitor.config.json', 'capacitor.config.js'].some(f => existsSync(join(root, f))) ? 'capacitor'
|
|
: (() => { try { const p = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); return !!(p.dependencies?.['react-native'] ?? p.devDependencies?.['react-native']); } catch { return false; } })() ? 'react-native'
|
|
: 'native';
|
|
// resolve, not join: an absolute play.build.dir / --dir stays absolute (join made an absolute path a folder under the root)
|
|
const dirs = cfg.dir ? [resolve(root, cfg.dir)] : [join(root, 'android'), root];
|
|
let dir = dirs.find(isGradleDir);
|
|
if (!dir) throw new Error(`no Gradle project (settings.gradle) in ${dirs.map(d => relative(root, d) || '.').join(' or ')}: set play.build.dir${framework === 'capacitor' ? ' (or npx cap add android)' : ''}`);
|
|
const unity = existsSync(join(dir, 'launcher')) && existsSync(join(dir, 'unityLibrary'));
|
|
const fw: AndroidFramework = unity ? 'unity' : framework;
|
|
const module = cfg.module ?? (unity ? 'launcher' : 'app');
|
|
if (!existsSync(join(dir, ...module.split(':')))) throw new Error(`module "${module}" not found in ${relative(root, dir) || '.'} (set play.build.module)`);
|
|
const flavor = cfg.flavor ? cfg.flavor[0]!.toUpperCase() + cfg.flavor.slice(1) : '';
|
|
if (fw === 'capacitor') notes.push('Capacitor: run npx cap sync android after the web build so the bundle has the current web assets');
|
|
if (fw === 'unity') notes.push('Unity: export the Gradle project from Unity (Build Settings > Android > Export Project) before each build');
|
|
if (fw === 'flutter') notes.push('Flutter: built with flutter build appbundle (build number = versionCode)');
|
|
if (!existsSync(join(dir, 'gradlew')) && !existsSync(join(dir, 'gradlew.bat'))) notes.push('no Gradle wrapper (gradlew) in the project: using gradle from PATH');
|
|
return { framework: fw, dir, module, task: `:${module}:bundle${flavor}Release`, notes };
|
|
}
|
|
|
|
/** Command line of the release bundle build. Version code / name go in as -PversionCode / -PversionName (the build
|
|
* file must read them: versionSourceIssues) and as the injected properties Android Studio uses. Those do NOT
|
|
* 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; 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}`] : []),
|
|
...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"). Only the file and alias go on argv; the passwords travel through signingEnv (ORG_GRADLE_PROJECT_*), never argv. */
|
|
export function signingArgs(s: AndroidSigning | null): string[] {
|
|
if (!s) return [];
|
|
return [`-Pandroid.injected.signing.store.file=${s.storeFile}`, `-Pandroid.injected.signing.key.alias=${s.keyAlias}`];
|
|
}
|
|
|
|
/** 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 ----
|
|
|
|
export interface VersionSourceIssue { field: 'versionCode' | 'versionName'; file: string; line: number | null; text: string; kind: 'literal' | 'other' | 'missing'; current: string; fix: string }
|
|
|
|
/** The application module's build file (build.gradle.kts first, then build.gradle), or null. */
|
|
export function appBuildFile(t: Pick<GradleTarget, 'dir' | 'module'>): string | null {
|
|
const base = join(t.dir, ...t.module.split(':'));
|
|
return ['build.gradle.kts', 'build.gradle'].map(f => join(base, f)).find(f => existsSync(f)) ?? null;
|
|
}
|
|
|
|
/** The line that reads the -P property storebound passes, keeping `current` as the default. Pure. */
|
|
export function versionFixLine(field: 'versionCode' | 'versionName', current: string, kotlin: boolean): string {
|
|
// Groovy: `versionCode (x) as Integer` parses as versionCode(x) as Integer, which hands Gradle a String (AGP 8.13:
|
|
// "Value is null"); the cast must sit inside the parentheses
|
|
if (kotlin) return field === 'versionCode' ? `versionCode = (project.findProperty("versionCode") as String?)?.toInt() ?: ${current}`
|
|
: `versionName = project.findProperty("versionName") as String? ?: "${current}"`;
|
|
return field === 'versionCode' ? `versionCode((project.findProperty('versionCode') ?: ${current}) as Integer)` : `versionName project.findProperty('versionName') ?: '${current}'`;
|
|
}
|
|
|
|
/** The block body after `name {` (brace matched, strings skipped), with the line number it starts on. Pure. */
|
|
function block(src: string, name: string): { body: string; line: number } | null {
|
|
const m = new RegExp(`\\b${name}\\s*\\{`).exec(src);
|
|
if (!m) return null;
|
|
let depth = 0, q = '';
|
|
const start = m.index + m[0].length;
|
|
for (let i = start; i < src.length; i++) {
|
|
const c = src[i]!;
|
|
if (q) { if (c === '\\') i++; else if (c === q) q = ''; continue; }
|
|
if (c === '"' || c === "'") q = c;
|
|
else if (c === '{') depth++;
|
|
else if (c === '}') { if (depth === 0) return { body: src.slice(start, i), line: src.slice(0, start).split('\n').length }; depth--; }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Whether defaultConfig of the build file reads versionCode / versionName from the project properties storebound
|
|
* passes (-PversionCode / -PversionName). A literal (`versionCode 1`), an environment variable or anything else keeps
|
|
* the AAB at that value whatever `play build` passes (seen live: a Capacitor app read versionCode from an env var and
|
|
* the AAB stayed at 1). Each issue carries the exact line to use. Pure apart from reading the file.
|
|
*/
|
|
export function versionSourceIssues(file: string, src = readFileSync(file, 'utf8')): VersionSourceIssue[] {
|
|
const kotlin = file.endsWith('.kts');
|
|
// comments out first (block and line; a `//` right after a colon is a URL), keeping the line numbers
|
|
const code = src.replace(/\/\*[\s\S]*?\*\//g, m => m.replace(/[^\n]/g, ' ')).replace(/(^|[^:])\/\/.*$/gm, '$1');
|
|
const dc = block(code, 'defaultConfig');
|
|
if (!dc) return [];
|
|
const out: VersionSourceIssue[] = [];
|
|
const lines = dc.body.split('\n');
|
|
for (const field of ['versionCode', 'versionName'] as const) {
|
|
const fallback = field === 'versionCode' ? '1' : '1.0';
|
|
let hit: { line: number; text: string; value: string } | null = null;
|
|
lines.forEach((l, i) => {
|
|
for (const stmt of l.split(';')) {
|
|
const m = new RegExp(`^\\s*(?:${field}|set${field[0]!.toUpperCase()}${field.slice(1)})\\b\\s*(?:=\\s*)?(.*)$`).exec(stmt);
|
|
if (m && !hit) hit = { line: dc.line + i, text: l.trim(), value: m[1]!.trim() };
|
|
}
|
|
});
|
|
if (!hit) {
|
|
out.push({ field, file, line: null, text: '', kind: 'missing', current: fallback, fix: versionFixLine(field, fallback, kotlin) });
|
|
continue;
|
|
}
|
|
const h = hit as { line: number; text: string; value: string };
|
|
const readsRe = new RegExp(`(?:findProperty|property|hasProperty|getProperty|gradleProperty)\\s*\\(\\s*['"]${field}['"]|properties\\s*\\[\\s*['"]${field}['"]`);
|
|
if (readsRe.test(h.value)) continue;
|
|
// a variable set from the property first (`def code = (project.findProperty('versionCode') ?: ...).toInteger()`,
|
|
// then `versionCode code`): follow the name to its definition anywhere in the file
|
|
const id = /^\(?\s*(?:project\.)?(?:ext\.)?([A-Za-z_]\w*)\s*(?:\.\s*\w+\s*\(\s*\)\s*)*(?:as\s+\w+\s*)?\)?$/.exec(h.value)?.[1];
|
|
if (id && !/^\d/.test(id)) {
|
|
const def = new RegExp(`(?:\\b(?:def|val|var|final)\\s+(?:\\w+\\s+)?|\\bext\\.|\\bextra\\[\\s*["']|^\\s*)${id}\\b["']?\\s*\\]?\\s*(?::\\s*\\w+\\??\\s*)?=\\s*([^\\n]+)`, 'm').exec(code);
|
|
if (def && readsRe.test(def[1]!)) continue;
|
|
}
|
|
const lit = /^\(?\s*(\d+|"[^"]*"|'[^']*')\s*\)?$/.exec(h.value);
|
|
const current = lit ? lit[1]!.replace(/^['"]|['"]$/g, '')
|
|
: field === 'versionCode' ? (/(?:\?:|\|\||,)\s*['"]?(\d+)['"]?/.exec(h.value)?.[1] ?? /\b(\d+)\b/.exec(h.value)?.[1] ?? fallback)
|
|
: (/['"](\d[\w.+-]*)['"]/.exec(h.value)?.[1] ?? fallback);
|
|
out.push({ field, file, line: h.line, text: h.text, kind: lit ? 'literal' : 'other', current, fix: versionFixLine(field, current, kotlin) });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** One warning per issue: where, why, and the line to use. Pure. */
|
|
export function versionSourceMessage(i: VersionSourceIssue, rel: string, framework?: AndroidFramework): string {
|
|
const where = i.line ? `${rel}:${i.line}` : rel;
|
|
const flag = i.field === 'versionCode' ? '-PversionCode' : '-PversionName';
|
|
const what = i.kind === 'missing' ? `defaultConfig sets no ${i.field}`
|
|
: i.kind === 'literal' ? `${i.field} is a literal (${i.text})` : `${i.field} does not come from the project property ${i.field} (${i.text})`;
|
|
const unity = framework === 'unity' ? ' (Unity export: the file is rewritten on every export; set Bundle Version Code / Version in Player Settings or edit it after each export)' : '';
|
|
return `${where}: ${what}, so ${flag} from play build is ignored and the AAB keeps ${i.kind === 'missing' ? 'whatever the manifest says' : i.current}; android.injected.version.* does not override it for bundleRelease. Use in defaultConfig: ${i.fix}${unity}`;
|
|
}
|
|
|
|
/** Environment that hands the upload key to the Android Gradle plugin (injected signing properties). */
|
|
export function signingEnv(s: AndroidSigning | null): Record<string, string> {
|
|
if (!s) return {};
|
|
return {
|
|
'ORG_GRADLE_PROJECT_android.injected.signing.store.file': s.storeFile,
|
|
'ORG_GRADLE_PROJECT_android.injected.signing.store.password': s.storePassword,
|
|
'ORG_GRADLE_PROJECT_android.injected.signing.key.alias': s.keyAlias,
|
|
'ORG_GRADLE_PROJECT_android.injected.signing.key.password': s.keyPassword,
|
|
};
|
|
}
|
|
|
|
/** AABs written since `since` (ms) below the folders a build writes to. Newest first. */
|
|
export function findBundles(dirs: string[], since: number): string[] {
|
|
const out: { f: string; t: number }[] = [];
|
|
const walk = (d: string, depth: number): void => {
|
|
let names: string[];
|
|
try { names = readdirSync(d); } catch { return; }
|
|
for (const n of names) {
|
|
const p = join(d, n);
|
|
let st;
|
|
try { st = statSync(p); } catch { continue; }
|
|
if (st.isDirectory()) { if (depth > 0 && n !== 'intermediates' && n !== 'tmp') walk(p, depth - 1); }
|
|
else if (n.endsWith('.aab') && st.mtimeMs >= since) out.push({ f: p, t: st.mtimeMs });
|
|
}
|
|
};
|
|
for (const d of dirs) walk(d, 6);
|
|
return out.sort((a, b) => b.t - a.t).map(x => x.f);
|
|
}
|
|
|
|
// ---- AAB manifest (aapt2 protobuf XmlNode) ----
|
|
|
|
type Field = { no: number; wire: number; v: number | Buffer };
|
|
|
|
function fields(b: Buffer): Field[] | null {
|
|
const out: Field[] = [];
|
|
let i = 0;
|
|
const varint = (): number => {
|
|
let r = 0, mul = 1;
|
|
for (let k = 0; k < 10; k++) {
|
|
if (i >= b.length) throw new Error('eof');
|
|
const x = b[i++]!;
|
|
r += (x & 127) * mul;
|
|
if (!(x & 128)) return r;
|
|
mul *= 128;
|
|
}
|
|
throw new Error('varint');
|
|
};
|
|
try {
|
|
while (i < b.length) {
|
|
const key = varint(), no = Math.floor(key / 8), wire = key & 7;
|
|
if (no === 0) return null;
|
|
if (wire === 0) out.push({ no, wire, v: varint() });
|
|
else if (wire === 2) { const n = varint(); if (i + n > b.length) return null; out.push({ no, wire, v: b.subarray(i, i + n) }); i += n; }
|
|
else if (wire === 5) { i += 4; } else if (wire === 1) { i += 8; } else return null;
|
|
}
|
|
} catch { return null; }
|
|
return i === b.length ? out : null;
|
|
}
|
|
|
|
/** Attributes (name -> raw value) of every element in an aapt2 proto XML, element name first. */
|
|
function elements(b: Buffer, out: { name: string; attrs: Record<string, string> }[] = [], depth = 0): { name: string; attrs: Record<string, string> }[] {
|
|
// aapt2 Resources.proto: XmlNode { XmlElement element = 1; string text = 2; SourcePosition source = 3 }
|
|
// XmlElement { XmlNamespace namespace_declaration = 1; string namespace_uri = 2; string name = 3; XmlAttribute attribute = 4; XmlNode child = 5 }
|
|
// XmlAttribute { string namespace_uri = 1; string name = 2; string value = 3; ...; Item compiled_item = 6 }
|
|
if (depth > 64) return out;
|
|
const node = fields(b);
|
|
const el = node?.find(f => f.no === 1 && f.wire === 2);
|
|
if (!el) return out;
|
|
const ef = fields(el.v as Buffer) ?? [];
|
|
const name = (ef.find(f => f.no === 3 && f.wire === 2)?.v as Buffer | undefined)?.toString('utf8') ?? '';
|
|
const attrs: Record<string, string> = {};
|
|
for (const a of ef.filter(f => f.no === 4 && f.wire === 2)) {
|
|
const af = fields(a.v as Buffer) ?? [];
|
|
const an = (af.find(f => f.no === 2)?.v as Buffer | undefined)?.toString('utf8');
|
|
let av = (af.find(f => f.no === 3)?.v as Buffer | undefined)?.toString('utf8') ?? '';
|
|
if (!av) {
|
|
// compiled_item { Prim prim = 7 { int_decimal_value = 6 ... } } or { Str str = 2 { value = 1 } }
|
|
const item = fields((af.find(f => f.no === 6)?.v as Buffer | undefined) ?? Buffer.alloc(0)) ?? [];
|
|
const prim = fields((item.find(f => f.no === 7)?.v as Buffer | undefined) ?? Buffer.alloc(0)) ?? [];
|
|
const n = prim.find(f => f.wire === 0);
|
|
const str = fields((item.find(f => f.no === 2)?.v as Buffer | undefined) ?? Buffer.alloc(0)) ?? [];
|
|
av = n ? String(n.v) : (str.find(f => f.no === 1)?.v as Buffer | undefined)?.toString('utf8') ?? '';
|
|
}
|
|
if (an) attrs[an] = av;
|
|
}
|
|
out.push({ name, attrs });
|
|
for (const c of ef.filter(f => f.no === 5 && f.wire === 2)) elements(c.v as Buffer, out, depth + 1);
|
|
return out;
|
|
}
|
|
|
|
export interface AabInfo { packageName: string; versionCode: number; versionName?: string; minSdk?: number; targetSdk?: number }
|
|
|
|
/** Package, versionCode and versionName of an Android App Bundle (base/manifest/AndroidManifest.xml). */
|
|
export function aabInfo(aab: string | Buffer): AabInfo {
|
|
const e = zipEntries(aab).find(x => x.name === 'base/manifest/AndroidManifest.xml');
|
|
if (!e) throw new Error('not an Android App Bundle (no base/manifest/AndroidManifest.xml)');
|
|
const els = elements(zipRead(aab, e));
|
|
const m = els.find(x => x.name === 'manifest');
|
|
if (!m?.attrs.package || !m.attrs.versionCode) throw new Error('AAB manifest without package / versionCode');
|
|
const sdk = els.find(x => x.name === 'uses-sdk')?.attrs;
|
|
return { packageName: m.attrs.package, versionCode: Number(m.attrs.versionCode), versionName: m.attrs.versionName || undefined,
|
|
minSdk: sdk?.minSdkVersion ? Number(sdk.minSdkVersion) : undefined, targetSdk: sdk?.targetSdkVersion ? Number(sdk.targetSdkVersion) : undefined };
|
|
}
|
|
|
|
/** Builds an aapt2 proto manifest (Resources.proto field numbers, as in a real AGP bundle: namespace declaration,
|
|
* then name, attributes, children; versionCode as a compiled int like aapt2 writes it). Tests and fixtures. */
|
|
const ANDROID_NS = 'http://schemas.android.com/apk/res/android';
|
|
export function encodeProtoManifest(els: { name: string; attrs: Record<string, string> }[]): Buffer {
|
|
const vint = (n: number): Buffer => { const o: number[] = []; do { let x = n % 128; n = Math.floor(n / 128); if (n) x |= 128; o.push(x); } while (n); return Buffer.from(o); };
|
|
const ld = (no: number, b: Buffer): Buffer => Buffer.concat([vint(no * 8 + 2), vint(b.length), b]);
|
|
const s = (no: number, t: string) => ld(no, Buffer.from(t, 'utf8'));
|
|
const element = (i: number): Buffer => {
|
|
const e = els[i]!;
|
|
const ns = i === 0 ? [ld(1, Buffer.concat([s(1, 'android'), s(2, ANDROID_NS)]))] : [];
|
|
const attrs = Object.entries(e.attrs).map(([k, v]) => {
|
|
const plain = k === 'package' || k === 'versionName';
|
|
// compiled_item = 6 { Primitive prim = 7 { int_decimal_value = 6 } } for numbers, like aapt2
|
|
const item = /^\d+$/.test(v) && !plain ? [ld(6, ld(7, Buffer.concat([vint(6 * 8), vint(Number(v))])))] : [];
|
|
return ld(4, Buffer.concat([plain ? Buffer.alloc(0) : s(1, ANDROID_NS), s(2, k), s(3, v), ...item]));
|
|
});
|
|
const child = i + 1 < els.length ? [ld(5, ld(1, element(i + 1)))] : [];
|
|
return Buffer.concat([...ns, s(3, e.name), ...attrs, ...child]);
|
|
};
|
|
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 };
|
|
}
|