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

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