Windows builds and moved credentials: play build runs gradlew.bat through cmd /c and takes C:/ keystore paths as absolute (pitfall 98); check names credential paths missing on this machine and a keystore the stored password / alias do not open (keytool) (pitfall 99); play / apple build write <out>/latest.json for CI; CI install from a git clone documented

This commit is contained in:
CRTkafa 2026-10-22 02:10:52 +08:00
parent 4fcee60ba6
commit 73d308164d
9 changed files with 95 additions and 9 deletions

View file

@ -190,6 +190,8 @@ export async function run(ctx: Ctx): Promise<void> {
const privacy = bundleTrackingIssues(ipaManifests(join(exportPath, ipa)));
if (privacy.length) throw new Error(`${relative(root, join(exportPath, ipa))} was built, but Apple would refuse it at upload:\n${privacy.map(x => ` ${x}`).join('\n')}\nFix the manifest and build again.`);
const result = { ipa: join(exportPath, ipa), bundleId: info.bundleId, version: info.version, build: info.build, web: web.bundle };
writeFileSync(join(out, 'latest.json'), `${JSON.stringify(result, null, 2)}
`);
if (json) printJson(result);
done(TAG, `${relative(root, result.ipa)}: ${info.bundleId} ${info.version} (${info.build}). Next: storebound apple upload ${relative(root, result.ipa)} --dry`);
}

View file

@ -21,7 +21,7 @@ import { playConsoleReminders } from './lib/console-plan';
import { APPLE_IAP_LIMITS, appleNameIssues, len, parseAppStoreListing, parsePlayListing } from './lib/copy';
import type { Ctx } from './lib/ctx';
import { dataSafetyIssues, dataSafetyRows } from './lib/data-safety';
import { credentialStatus, reviewDemoAccount } from './lib/credentials';
import { credentialFileIssues, credentialStatus, reviewDemoAccount } from './lib/credentials';
import { log, printJson, warn } from './lib/log';
import { profileIssues } from './lib/profile';
import { detectProject, grepOwn, where } from './lib/scan/project';
@ -150,6 +150,8 @@ export async function run(ctx: Ctx): Promise<void> {
// template placeholders in the credentials ("YOUR_KEY_ID", "/absolute/path/to/...", "<...>"): missing, never a credential
const ph = credentialStatus(ctx.loaded.root).filter(c => c.status === 'placeholder').map(c => c.key);
if (ph.length) note(`credentials: ${ph.join(', ')} still hold template placeholders (env or .storebound/credentials.json): treated as missing; put the real value in or leave it empty`);
// credential files that are not on this machine, an upload keystore the stored password / alias do not open
try { for (const i of credentialFileIssues(ctx.loaded.root)) problems.push(`credentials: ${i}`); } catch (e) { warn('check', `credential files: ${(e as Error).message}`); }
// scan.ignore entries that accept no finding (a typo, a title that changed, a fixed issue) are never silent
if (config.scan?.ignore?.length) {
const { runScan } = await import('./lib/scan');

View file

@ -26,6 +26,7 @@
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { isAbsolute, join, resolve } from 'node:path';
import * as rt from './rt';
export interface ServiceAccount { client_email: string; private_key: string; token_uri?: string }
export interface AscKey { keyId: string; issuerId: string; pem: string }
@ -324,7 +325,7 @@ export function androidSigning(root: string): AndroidSigning | null {
const storePassword = value(root, 'ANDROID_KEYSTORE_PASSWORD'), keyAlias = value(root, 'ANDROID_KEY_ALIAS');
if (!storePassword || !keyAlias) throw new Error('ANDROID_KEYSTORE is set but ANDROID_KEYSTORE_PASSWORD / ANDROID_KEY_ALIAS are not (env or .storebound/credentials.json)');
const abs = storeFile.replace(/^~(?=\/)/, homedir());
return { storeFile: abs.startsWith('/') ? abs : join(root, abs), storePassword, keyAlias, keyPassword: value(root, 'ANDROID_KEY_PASSWORD') ?? storePassword };
return { storeFile: isAbsolute(abs) ? abs : join(root, abs), storePassword, keyAlias, keyPassword: value(root, 'ANDROID_KEY_PASSWORD') ?? storePassword };
}
/** App Store Connect vendor number for sales and finance reports (Payments and Financial Reports, top left): the
@ -334,3 +335,33 @@ export function ascVendorNumber(root: string): string | null {
const v = value(root, 'ASC_VENDOR_NUMBER');
return v && /^\d{5,12}$/.test(v.trim()) ? v.trim() : null;
}
/** Credential paths that do not exist (a machine move left another computer's absolute paths) and an upload keystore
* the stored password / alias do not open: each names the field. `keytool` runs only when it is on PATH. */
export function credentialFileIssues(root: string, run: (argv: string[]) => { ok: boolean; out: string } | null = keytool): string[] {
const issues: string[] = [];
const asPath = (v: string) => { const p = v.replace(/^~(?=[/\\])/, homedir()); return isAbsolute(p) ? p : join(root, p); };
for (const k of ['PLAY_SERVICE_ACCOUNT', 'ASC_P8', 'ANDROID_KEYSTORE'] as const) {
let v: string | undefined;
try { v = value(root, k); } catch { continue; }
if (!v || /^\s*\{/.test(v) || /BEGIN [A-Z ]*PRIVATE KEY/.test(v)) continue;
if (!existsSync(asPath(v))) issues.push(`${k}: ${v} does not exist on this machine (moved from another computer?): point it at the file here`);
}
let s: AndroidSigning | null = null;
try { s = androidSigning(root); } catch (e) { issues.push((e as Error).message); }
if (s && existsSync(s.storeFile)) {
const r = run(['-list', '-keystore', s.storeFile, '-storepass', s.storePassword, '-alias', s.keyAlias]);
if (r && !r.ok) issues.push(/password|tampered|Keystore was tampered/i.test(r.out)
? 'ANDROID_KEYSTORE_PASSWORD does not open the upload keystore (keytool -list failed): check the password (android/keystore.properties may hold the right one)'
: /alias|does not exist/i.test(r.out) ? `ANDROID_KEY_ALIAS "${s.keyAlias}" is not in the upload keystore` : `the upload keystore does not open with the stored password / alias: ${r.out.trim().split('\n')[0]}`);
}
return issues;
}
function keytool(argv: string[]): { ok: boolean; out: string } | null {
const bin = process.env.JAVA_HOME ? join(process.env.JAVA_HOME, 'bin', process.platform === 'win32' ? 'keytool.exe' : 'keytool') : 'keytool';
try {
const r = rt.spawnSync([bin, ...argv], { timeout: 20_000 });
return { ok: r.success, out: `${r.stdout.toString()}${r.stderr.toString()}` };
} catch { return null; }
}

View file

@ -48,7 +48,7 @@ export function pickGradle(root: string, cfg: PlayBuildConfig = {}): GradleTarge
if (fw === 'capacitor') notes.push('Capacitor: run npx cap sync android after the web build so the bundle has the current web assets');
if (fw === 'unity') notes.push('Unity: export the Gradle project from Unity (Build Settings > Android > Export Project) before each build');
if (fw === 'flutter') notes.push('Flutter: built with flutter build appbundle (build number = versionCode)');
if (!existsSync(join(dir, process.platform === 'win32' ? 'gradlew.bat' : 'gradlew'))) notes.push('no Gradle wrapper (gradlew) in the project: using gradle from PATH');
if (!existsSync(join(dir, 'gradlew')) && !existsSync(join(dir, 'gradlew.bat'))) notes.push('no Gradle wrapper (gradlew) in the project: using gradle from PATH');
return { framework: fw, dir, module, task: `:${module}:bundle${flavor}Release`, notes };
}
@ -57,10 +57,11 @@ 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 }): string[] {
export function gradleArgs(t: GradleTarget, o: { versionCode?: number; versionName?: string; flavor?: string; hasWrapper: boolean }, 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] : [])];
return [o.hasWrapper ? (process.platform === 'win32' ? 'gradlew.bat' : './gradlew') : 'gradle', t.task, '--no-daemon', '--console=plain',
// 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}`] : [])];
}

View file

@ -59,7 +59,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, 'gradlew'));
const hasWrapper = existsSync(join(t.dir, process.platform === 'win32' ? 'gradlew.bat' : 'gradlew'));
const argv = gradleArgs(t, { versionCode: code || undefined, versionName, flavor, hasWrapper });
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
@ -117,6 +117,8 @@ export async function run(ctx: Ctx): Promise<void> {
const dest = join(out, `${info.packageName}-${info.versionCode}.aab`);
copyFileSync(aab, dest);
const result = { aab: dest, packageName: info.packageName, versionCode: info.versionCode, versionName: info.versionName ?? null, web: web.bundle };
writeFileSync(join(out, 'latest.json'), `${JSON.stringify(result, null, 2)}
`);
if (json) printJson(result);
done(TAG, `${relative(root, dest)}: ${info.packageName} ${info.versionName ?? ''} (${info.versionCode}). Next: storebound play upload ${relative(root, dest)} --track internal --dry`);
}