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
|
|
@ -23,7 +23,7 @@ import { parseMobileprovision, toXmlPlist } from '../lib/plist';
|
|||
import { nextBuildNumber } from '../lib/release';
|
||||
import { exportOptions, profileDirs } from '../lib/signing';
|
||||
import { capacitorProject, webStep } from '../lib/web-assets';
|
||||
import { archiveArgs, type AscAuth, distributionErrors, exportArgs, foreignRsync, remoteBuildSession, xcodePath, flutterConfigArgs, ipaInfo, pickXcode, scanXcode, showCmd } from '../lib/xcode';
|
||||
import { archiveArgs, type AscAuth, distributionErrors, exportArgs, foreignRsync, remoteBuildSession, xcodePath, flutterConfigArgs, ipaInfo, ipaVersionProblem, pickXcode, scanXcode, showCmd } from '../lib/xcode';
|
||||
import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files';
|
||||
import { ascClient } from './asc';
|
||||
import { findBundleId } from './signing';
|
||||
|
|
@ -189,6 +189,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const ipa = readdirSync(exportPath).find(f => f.endsWith('.ipa'))!;
|
||||
const info = ipaInfo(join(exportPath, ipa));
|
||||
if (info.bundleId !== bundleId) warn(TAG, `the IPA's bundle id is ${info.bundleId}, the config says ${bundleId}`);
|
||||
const vwhy = ipaVersionProblem(info.version, version, 'the requested --version');
|
||||
if (vwhy) { if (a.has('allow-version-mismatch')) warn(TAG, vwhy); else throw new Error(`${relative(root, join(exportPath, ipa))}: ${vwhy}`); }
|
||||
if (info.build !== build) warn(TAG, `the IPA has build ${info.build}, not ${build}: the project sets CFBundleVersion itself (use $(CURRENT_PROJECT_VERSION) in Info.plist)`);
|
||||
// ITMS-91064 (Invalid Binary after the upload): NSPrivacyTracking / NSPrivacyTrackingDomains of every manifest in the IPA
|
||||
const privacy = bundleTrackingIssues(ipaManifests(join(exportPath, ipa)));
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
// An IPA whose build number is already on App Store Connect fails with exit 1 (a stale IPA left by a failed
|
||||
// `apple build`, pitfall 53); only --allow-existing (flows that resume after a finished upload) re-checks it instead. Before any network call every PrivacyInfo.xcprivacy in the IPA
|
||||
// (app root, Frameworks/*.framework, bundles; XML or binary) is checked: NSPrivacyTracking and NSPrivacyTrackingDomains
|
||||
// must agree (ITMS-91064, Invalid Binary a minute after the upload), else nothing is uploaded.
|
||||
// must agree (ITMS-91064, Invalid Binary a minute after the upload), else nothing is uploaded. The IPA's version
|
||||
// (CFBundleShortVersionString) must be the editable App Store version's (else the build lands in the wrong train);
|
||||
// --allow-version-mismatch goes on anyway.
|
||||
// --via api (default): the App Store Connect API build upload (Build Uploads, API 4.1+): POST /v1/buildUploads,
|
||||
// reserve /v1/buildUploadFiles, PUT the parts, commit with the MD5, then poll the upload state. Same API key, no
|
||||
// Xcode needed, works on any OS.
|
||||
|
|
@ -22,7 +24,7 @@ import type { Ctx } from '../lib/ctx';
|
|||
import { confirm, need } from '../lib/ctx';
|
||||
import { done, log, printJson, warn } from '../lib/log';
|
||||
import { compareBuild, nextBuildNumber, nextPollDelay, parseDuration, WAIT_EXIT } from '../lib/release';
|
||||
import { altoolArgs, buildUploadReqs, exportedIpas, ipaInfo, showCmd, UPLOAD_DONE, uploadProblems } from '../lib/xcode';
|
||||
import { altoolArgs, buildUploadReqs, exportedIpas, ipaInfo, ipaVersionProblem, showCmd, UPLOAD_DONE, uploadProblems } from '../lib/xcode';
|
||||
import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files';
|
||||
import { BUILD_WAIT_EXIT } from '../lib/build-ops';
|
||||
import { type Asc, ascClient, type J } from './asc';
|
||||
|
|
@ -246,6 +248,11 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
|
||||
const c = await ascClient(root, bundleId, config.apple?.appId);
|
||||
const appId = await c.app();
|
||||
// the IPA must belong to the version train being prepared (the editable App Store version): MARKETING_VERSION 1.0
|
||||
// from a project that ignored the intended version otherwise lands in the wrong train
|
||||
const editable = await c.editableVersion().catch(() => null);
|
||||
const trainWhy = ipaVersionProblem(info.version, editable?.attributes?.versionString as string | undefined, 'the editable App Store version');
|
||||
if (trainWhy) { if (a.has('allow-version-mismatch')) warn(TAG, trainWhy); else throw new Error(`${relative(root, ipa)}: not uploaded, ${trainWhy}`); }
|
||||
const builds = await c.all(`/v1/builds?filter[app]=${appId}&limit=200&fields[builds]=version,processingState`);
|
||||
const numbers = builds.map(b => String(b.attributes.version));
|
||||
const next = nextBuildNumber(numbers);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { trackingIssue } from './lib/privacy-manifest';
|
|||
import { type AsoFinding, auditAso } from './lib/aso';
|
||||
import { draftLocales } from './lib/aso-intel';
|
||||
import { appleName, playCodes } from './lib/config';
|
||||
import { fieldsMentions } from './lib/cross-store';
|
||||
import { appBuildFile, pickGradle, versionSourceIssues, versionSourceMessage } from './lib/gradle';
|
||||
import { playConsoleReminders } from './lib/console-plan';
|
||||
import { APPLE_IAP_LIMITS, appleNameIssues, len, parseAppStoreListing, parsePlayListing } from './lib/copy';
|
||||
|
|
@ -84,6 +85,10 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const def = copy.find(x => x.lang === (config.play!.defaultLanguage ?? 'en-US'));
|
||||
const same = copy.filter(x => def && x !== def && x.whatsNew && x.whatsNew === def.whatsNew && x.lang.slice(0, 2) !== def.lang.slice(0, 2));
|
||||
if (same.length) note(`what's new of ${same.map(x => x.lang).join(', ')} is the same text as ${def!.lang}: translate it`);
|
||||
// release notes naming iOS / the App Store are refused at release time (pitfall 77): say so now (a notes block the
|
||||
// file scan did not reach, e.g. one outside a fenced block, still counts)
|
||||
const seen = new Set(listingFileMentions(ctx.loaded, { apple: false, play: true }).filter(h => h.field === 'whatsNew').map(h => h.locale));
|
||||
for (const c of copy) if (c.whatsNew && !seen.has(c.lang)) for (const h of fieldsMentions('play', c.lang, { whatsNew: c.whatsNew }, config.listing?.allowPlatformMentions)) problems.push(`Google Play release notes: ${hitLine(h)}`);
|
||||
return `${copy.length} languages`;
|
||||
});
|
||||
if (config.apple) tryDo('appstore listing', () => {
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { createHash } from 'node:crypto';
|
|||
import type { Ctx } from '../lib/ctx';
|
||||
import { confirm, need } from '../lib/ctx';
|
||||
import { done, log, warn, printJson } from '../lib/log';
|
||||
import { aabInfo } from '../lib/gradle';
|
||||
import { aabInfo, assertAabSigned } from '../lib/gradle';
|
||||
import { playTargetSdkProblem, targetSdkFile } from '../lib/store-minimums';
|
||||
import { playClient } from './client';
|
||||
import { usedVersionCodes } from './next-version-code';
|
||||
|
|
@ -52,6 +52,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (live && !a.has('dry') && !(await confirm(ctx, `Release to PRODUCTION as "${live.status}" (public once Google approves)?`))) return;
|
||||
|
||||
if (aab) {
|
||||
assertAabSigned(aab);
|
||||
const sdkWhy = playTargetSdkProblem(aabInfo(aab).targetSdk, { where: targetSdkFile(root) });
|
||||
if (sdkWhy) throw new Error(`${aab}: not released, ${sdkWhy}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|||
import { join, relative } from 'node:path';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { confirm, need } from '../lib/ctx';
|
||||
import { aabInfo } from '../lib/gradle';
|
||||
import { aabInfo, assertAabSigned } from '../lib/gradle';
|
||||
import { playTargetSdkProblem, targetSdkFile } from '../lib/store-minimums';
|
||||
import { done, log, printJson } from '../lib/log';
|
||||
import { nextVersionCode } from '../lib/release';
|
||||
|
|
@ -43,6 +43,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
// Play refuses bundles below its current target API level (cli/lib/store-minimums.ts): stop before any network call
|
||||
const sdkWhy = playTargetSdkProblem(info.targetSdk, { where: targetSdkFile(root) });
|
||||
if (sdkWhy) throw new Error(`${relative(root, aab)}: not uploaded, ${sdkWhy}`);
|
||||
assertAabSigned(aab, relative(root, aab));
|
||||
const size = statSync(aab).size;
|
||||
const dry = a.has('dry');
|
||||
const json = a.has('json');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue