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

@ -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

View file

@ -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)));

View file

@ -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);

View file

@ -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', () => {

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']);

View file

@ -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) {

View file

@ -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}`);
}

View file

@ -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');

View file

@ -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`).

View file

@ -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' }] : []),
]);

View file

@ -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();
});
});