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
|
|
@ -6,6 +6,10 @@
|
|||
- `storebound apps ignore|unignore [--store apple|play]`: hide dormant apps, or one store side of an app, from watch, reads and the desktop (MCP apps_ignore / apps_unignore).
|
||||
- Reviews: a store read that fails is an error, not an empty "no new reviews" list; the Reviews tab notes Apple API lag and Play's 7-day window (pitfall 106).
|
||||
- Desktop: live store thumbnails load right away, also in a hidden window.
|
||||
- `play build` passes the upload key (ANDROID_KEYSTORE, ...) to Gradle as `-Pandroid.injected.signing.*` too (passwords masked in the printed command), so a build file that signs only from a local keystore.properties no longer gives an unsigned AAB in CI (pitfall 107).
|
||||
- `play build`, `play upload` and `play release` of a local AAB verify it is signed (META-INF entries, `jarsigner -verify` when a JDK exists) and fail with the fix before any upload.
|
||||
- `apple build --version` and `apple upload` refuse an IPA whose CFBundleShortVersionString is not the intended / editable App Store version; `--allow-version-mismatch` overrides (pitfall 108).
|
||||
- Play release notes that name iOS, iPhone or the App Store are caught by `storebound check` and `play build`, not only at release time (pitfall 109).
|
||||
|
||||
## 0.8.36 · 2026-10-22
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -1283,3 +1283,32 @@ that fails without the fix (see CONTRIBUTING.md).
|
|||
- **Do:** read the "read ... ago" stamp and refresh; for Play keep your own history; compare with the console if in doubt.
|
||||
- **Guard:** `settleStoreReads` (`cli/lib/reviews.ts`) throws when every asked store failed (`tests/reviews.test.ts`);
|
||||
the Reviews tab states the Play 7-day window and that Apple's API can lag.
|
||||
|
||||
## 107. `play build` in CI gave an unsigned AAB, Play said 403 "All uploaded bundles must be signed"
|
||||
|
||||
- **What happens:** the project's `app/build.gradle` signs only from a local `keystore.properties` (absent in CI). The
|
||||
upload key in `ANDROID_KEYSTORE` / `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD` reached
|
||||
Gradle only through `ORG_GRADLE_PROJECT_*`, which that build file does not use; the AAB came out unsigned and the
|
||||
refusal came only at upload.
|
||||
- **Do:** set the four variables (env or `.storebound/credentials.json`); `play build` now also passes them as
|
||||
`-Pandroid.injected.signing.store.file|store.password|key.alias|key.password`. The printed command and the plan show
|
||||
the passwords as `***`.
|
||||
- **Guard:** `signingArgs` / `maskArgs` (`cli/lib/gradle.ts`); right after the build, and before `play upload` /
|
||||
`play release` of a local AAB, `assertAabSigned` checks the META-INF `*.SF` + `*.RSA|DSA|EC` entries (and runs
|
||||
`jarsigner -verify` when a JDK is on PATH) and fails with the fix, before any upload (`tests/build-sign.test.ts`).
|
||||
|
||||
## 108. `apple build` exported MARKETING_VERSION 1.0, not the version being released
|
||||
|
||||
- **What happens:** the Xcode project ignored the version the release script intended (an env var the project read), so
|
||||
the IPA's `CFBundleShortVersionString` was 1.0 and the build went into the wrong version train.
|
||||
- **Do:** make the project read the intended version (`xcodebuild MARKETING_VERSION=x.y.z`, or set it in the project),
|
||||
build again. `--allow-version-mismatch` goes on anyway.
|
||||
- **Guard:** `ipaVersionProblem` (`cli/lib/xcode.ts`): `apple build --version X` refuses an IPA with another version, and
|
||||
`apple upload` compares the IPA with the editable App Store version before any upload (`tests/build-sign.test.ts`).
|
||||
|
||||
## 109. Play release notes naming iOS / iPhone / App Store were refused only at release time
|
||||
|
||||
- **What happens:** the Play Metadata policy rule (pitfall 77) stopped `play release` after the long build.
|
||||
- **Do:** write the notes without other platforms or stores; `[play]` / `[apple]` tags split shared notes.
|
||||
- **Guard:** `storebound check` reports the "What's new" of every Play language, `play build` refuses before Gradle runs
|
||||
(`notesGuard`), `play upload` / `play release` refuse before any edit (`refusePlayNotes`).
|
||||
|
|
|
|||
|
|
@ -57,8 +57,9 @@ export const makeIpa = (infoPlist: Buffer | string, app = 'Example.app'): Buffer
|
|||
]);
|
||||
|
||||
/** An AAB with an aapt2 proto manifest. */
|
||||
export const makeAab = (pkg: string, versionCode: number, versionName = '1.0.0'): Buffer => makeZip([
|
||||
export const makeAab = (pkg: string, versionCode: number, versionName = '1.0.0', signed = true): Buffer => makeZip([
|
||||
{ name: 'BundleConfig.pb', data: Buffer.from([8, 1]) },
|
||||
{ name: 'base/manifest/AndroidManifest.xml', data: encodeProtoManifest([{ name: 'manifest', attrs: { package: pkg, versionCode: String(versionCode), versionName } }, { name: 'uses-sdk', attrs: { minSdkVersion: '24', targetSdkVersion: '36' } }]) },
|
||||
{ name: 'base/dex/classes.dex', data: Buffer.alloc(512, 1) },
|
||||
...(signed ? [{ name: 'META-INF/UPLOAD.SF', data: 'sf' }, { name: 'META-INF/UPLOAD.RSA', data: 'rsa' }] : []),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -3,17 +3,18 @@ import { createPrivateKey, createPublicKey, verify } from 'node:crypto';
|
|||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { notesGuard } from '../cli/play/build';
|
||||
import { installedProfiles } from '../cli/apple/build';
|
||||
import { androidSigning, ascKeyFile, credentialFileIssues } from '../cli/lib/credentials';
|
||||
import { buildCsr, csrContent, newSigningKey, spkiOfKey } from '../cli/lib/csr';
|
||||
import { aabInfo, appBuildFile, findBundles, gradleArgs, pickGradle, signingEnv, validatePlayBuild, versionSourceIssues, versionSourceMessage } from '../cli/lib/gradle';
|
||||
import { aabInfo, aabSignedEntries, appBuildFile, assertAabSigned, findBundles, gradleArgs, maskArgs, pickGradle, signingEnv, validatePlayBuild, versionSourceIssues, versionSourceMessage } from '../cli/lib/gradle';
|
||||
import { parseBinaryPlist, parseMobileprovision, parsePlist, parseXmlPlist, toXmlPlist } from '../cli/lib/plist';
|
||||
import {
|
||||
type ApiCert, type ApiProfile, certSha1, certSpki, certificateReq, explainCertError, exportOptions, parseIdentities, pickCertificate, planCapabilities,
|
||||
planProfile, profileDirs, validateSigning,
|
||||
} from '../cli/lib/signing';
|
||||
import { validateConfig } from '../cli/lib/config';
|
||||
import { altoolArgs, archiveArgs, buildUploadReqs, exportArgs, flutterConfigArgs, ipaInfo, pickXcode, scanXcode, showCmd, uploadProblems, type XcodeScan } from '../cli/lib/xcode';
|
||||
import { altoolArgs, archiveArgs, buildUploadReqs, exportArgs, flutterConfigArgs, ipaInfo, ipaVersionProblem, pickXcode, scanXcode, showCmd, uploadProblems, type XcodeScan } from '../cli/lib/xcode';
|
||||
import { zipEntries, zipRead } from '../cli/lib/zip';
|
||||
import { makeAab, makeIpa, makeZip } from './build-fixtures';
|
||||
import { TOOLS } from '../cli/mcp-tools';
|
||||
|
|
@ -524,3 +525,46 @@ describe('storebound aab (CI reads the version code without uploading)', () => {
|
|||
expect(JSON.parse(r.stdout.toString())).toMatchObject({ packageName: 'com.example.game', versionCode: 4321, versionName: '3.1.0' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('signed AAB, masked signing, IPA version train, release notes', () => {
|
||||
const s = { storeFile: '/k/upload.jks', storePassword: 'pw-store', keyAlias: 'upload', keyPassword: 'pw-key' };
|
||||
const t = { framework: 'native' as const, dir: '/p', module: 'app', task: ':app:bundleRelease', notes: [] };
|
||||
test('gradleArgs passes the injected signing properties, maskArgs hides the passwords', () => {
|
||||
const argv = gradleArgs(t, { hasWrapper: true, signing: s }, 'linux');
|
||||
expect(argv).toContain('-Pandroid.injected.signing.store.file=/k/upload.jks');
|
||||
expect(argv).toContain('-Pandroid.injected.signing.store.password=pw-store');
|
||||
expect(argv).toContain('-Pandroid.injected.signing.key.alias=upload');
|
||||
expect(argv).toContain('-Pandroid.injected.signing.key.password=pw-key');
|
||||
const shown = maskArgs(argv).join(' ');
|
||||
expect(shown).not.toContain('pw-store');
|
||||
expect(shown).not.toContain('pw-key');
|
||||
expect(shown).toContain('store.password=***');
|
||||
expect(shown).toContain('key.alias=upload');
|
||||
expect(gradleArgs(t, { hasWrapper: true }, 'linux').join(' ')).not.toContain('signing');
|
||||
});
|
||||
test('an unsigned AAB is refused, a signed one passes', () => {
|
||||
const unsigned = join(tmp, 'unsigned.aab'), signed = join(tmp, 'signed.aab');
|
||||
writeFileSync(unsigned, makeAab('com.example.app', 3, '1.0.0', false));
|
||||
writeFileSync(signed, makeZip([{ name: 'BundleConfig.pb', data: 'x' }, { name: 'META-INF/UPLOAD.SF', data: 'x' }, { name: 'META-INF/UPLOAD.RSA', data: 'x' }]));
|
||||
expect(aabSignedEntries(unsigned)).toBe(false);
|
||||
expect(() => assertAabSigned(unsigned, 'unsigned.aab', () => null)).toThrow('not signed');
|
||||
expect(() => assertAabSigned(unsigned, 'unsigned.aab', () => null)).toThrow('ANDROID_KEYSTORE');
|
||||
expect(() => assertAabSigned(signed, 'signed.aab', () => null)).not.toThrow();
|
||||
expect(() => assertAabSigned(signed, 'signed.aab', () => ({ ok: false, out: 'jar is unsigned.' }))).toThrow('jarsigner -verify');
|
||||
expect(() => assertAabSigned(signed, 'signed.aab', () => ({ ok: true, out: 'jar verified.' }))).not.toThrow();
|
||||
});
|
||||
test('the IPA version must be the version train being prepared', () => {
|
||||
expect(ipaVersionProblem('1.0', '1.4.0', 'the editable App Store version')).toContain('--allow-version-mismatch');
|
||||
expect(ipaVersionProblem('1.0', '1.4.0', 'x')).toContain('MARKETING_VERSION=1.4.0');
|
||||
expect(ipaVersionProblem('1.4.0', '1.4.0', 'x')).toBeNull();
|
||||
expect(ipaVersionProblem('1.0', undefined, 'x')).toBeNull();
|
||||
});
|
||||
test('play build refuses release notes that name iOS before the build', () => {
|
||||
const d = dir('notes');
|
||||
writeFileSync(join(d, 'listing.md'), '## English\n<!-- play: en-US -->\n### Title\n```\nApp\n```\n### Short\n```\nShort text\n```\n### Full\n```\nFull text\n```\n### What\'s new\n```\nNow also on iPhone\n```\n');
|
||||
const ctx = { args: { list: () => [] }, loaded: { root: d, resolve: (p: string) => join(d, p), config: { app: { name: 'App' }, play: { listing: 'listing.md' } } } } as never;
|
||||
expect(() => notesGuard(ctx)).toThrow('iPhone');
|
||||
writeFileSync(join(d, 'listing.md'), readFileSync(join(d, 'listing.md'), 'utf8').replace('Now also on iPhone', 'Faster start'));
|
||||
expect(() => notesGuard(ctx)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue