From 73d308164d2adb2658d9555964997570a643cbb7 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 02:10:52 +0800 Subject: [PATCH] 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 /latest.json for CI; CI install from a git clone documented --- CHANGELOG.md | 6 ++++++ cli/apple/build.ts | 2 ++ cli/check.ts | 4 +++- cli/lib/credentials.ts | 33 ++++++++++++++++++++++++++++++++- cli/lib/gradle.ts | 7 ++++--- cli/play/build.ts | 4 +++- docs/getting-started.md | 6 ++++++ skills/store-pitfalls/SKILL.md | 20 ++++++++++++++++++++ tests/build-sign.test.ts | 22 +++++++++++++++++++--- 9 files changed, 95 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d0b8bb..bb958cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- **Windows builds.** `play build` runs `gradlew.bat` through `cmd /c` (it was looked up on PATH only) and takes + `C:/...` keystore paths as absolute. Pitfall 98. +- **`check` verifies credential files.** Every credential path that does not exist on this machine and an upload keystore + the stored password / alias do not open (keytool) are named by field. Pitfall 99. +- `play build` / `apple build` write `/latest.json` with the AAB / IPA path for CI; CI install from a git clone documented. + - **Scripts that call the store APIs themselves are named.** `androidpublisher.googleapis.com`, the googleapis androidpublisher client, `api.appstoreconnect.apple.com`, altool and Transporter in CI files and scripts: `check` and `release status` warn, `scan` reports `direct-store-api`, each with file:line and the storebound command. Pitfall 97. diff --git a/cli/apple/build.ts b/cli/apple/build.ts index 62c5db4..dcb71dc 100644 --- a/cli/apple/build.ts +++ b/cli/apple/build.ts @@ -190,6 +190,8 @@ export async function run(ctx: Ctx): Promise { 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`); } diff --git a/cli/check.ts b/cli/check.ts index 2e2ebd0..df40330 100644 --- a/cli/check.ts +++ b/cli/check.ts @@ -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 { // 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'); diff --git a/cli/lib/credentials.ts b/cli/lib/credentials.ts index 34c890c..3b36e9d 100644 --- a/cli/lib/credentials.ts +++ b/cli/lib/credentials.ts @@ -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; } +} diff --git a/cli/lib/gradle.ts b/cli/lib/gradle.ts index 7ecfc8c..ddafbce 100644 --- a/cli/lib/gradle.ts +++ b/cli/lib/gradle.ts @@ -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}`] : [])]; } diff --git a/cli/play/build.ts b/cli/play/build.ts index a6a8ba5..91d12c9 100644 --- a/cli/play/build.ts +++ b/cli/play/build.ts @@ -59,7 +59,7 @@ export async function run(ctx: Ctx): Promise { } 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 { 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`); } diff --git a/docs/getting-started.md b/docs/getting-started.md index 02f2c56..ea6c825 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -152,6 +152,12 @@ plain Markdown and the CLI is a normal command. and `--command ""` to choose how the MCP server starts. It only adds or updates the `storebound` entry and its own skill folders; everything else in those files stays as it is, and re-running it is safe. +**CI servers (Jenkins, Forgejo / GitHub Actions):** use the npm package (`npx -y storebound@` or +`bunx storebound@`). From a git clone there is no `dist/` (it is built only for npm), so `node dist/storebound.mjs` +fails: run `bun install` in the clone and call `bin/storebound` (Bun runs `cli/index.ts`), e.g. symlink it onto PATH. +`storebound play build` and `apple build` write `/latest.json` (`aab` / `ipa` path, ids, version): read it instead of +globbing the output folder (or pass `--json`). + **Windows:** an MCP client cannot start `npx` directly (it is `npx.cmd`). `install` writes the working form for you; by hand use `"command": "cmd", "args": ["/c", "npx", "-y", "storebound@", "mcp"]` (or `bunx storebound mcp` with Bun). If the server shows "connection closed", check this first ([pitfall 94](../skills/store-pitfalls/SKILL.md)). diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 3e3eb02..061f84a 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1189,3 +1189,23 @@ that fails without the fix (see CONTRIBUTING.md). googleapis androidpublisher client, `api.appstoreconnect.apple.com`, `altool` / `iTMSTransporter` in CI files and scripts (not storebound's own checkout, never node_modules); `scan` rule `direct-store-api`, `check` and `release status` warnings with file:line and the storebound command (`tests/fastlane-traces.test.ts`). + +## 98. `play build` on Windows: "Executable not found in $PATH: gradlew.bat"; "C:/..." keystore "does not exist" +- **Symptom:** `storebound play build` stopped before Gradle on Windows although `android/gradlew.bat` exists; with + `ANDROID_KEYSTORE` set to an absolute `C:/...` path it said the keystore file does not exist. +- **Cause:** a bare `gradlew.bat` is searched on PATH only, never in the working folder; absolute paths were recognised + only when they started with `/`, so `C:/...` was joined onto the project folder. +- **Do:** update storebound. Until then run Gradle by hand and use a project-relative keystore path. +- **Guard:** `gradleArgs` in `cli/lib/gradle.ts` runs `cmd /c gradlew.bat` on Windows; `androidSigning` uses + `isAbsolute` (`tests/build-sign.test.ts`). + +## 99. Credentials still pointed at the old computer, and the keystore password did not open the keystore +- **Symptom:** after a machine move `ANDROID_KEYSTORE`, `PLAY_SERVICE_ACCOUNT` and `ASC_P8` held the old machine's + absolute paths, and `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_ALIAS` did not open the upload keystore (keytool exit 1); + `check` passed and the build failed late. +- **Do:** point each path at the file on this machine; take the password and alias that open the keystore (an + `android/keystore.properties` often holds them). Verify with `keytool -list -keystore -alias `. +- **Guard:** `credentialFileIssues` in `cli/lib/credentials.ts`: `check` names every credential path that does not + exist and runs `keytool -list` with the stored password and alias (when keytool is on PATH or in JAVA_HOME), naming + the bad field (`tests/build-sign.test.ts`). `play build` / `apple build` also write `/latest.json` so CI + needs no glob for the AAB / IPA. diff --git a/tests/build-sign.test.ts b/tests/build-sign.test.ts index 3364713..83f1f1d 100644 --- a/tests/build-sign.test.ts +++ b/tests/build-sign.test.ts @@ -4,7 +4,7 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, ut import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { installedProfiles } from '../cli/apple/build'; -import { androidSigning, ascKeyFile } from '../cli/lib/credentials'; +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 { parseBinaryPlist, parseMobileprovision, parsePlist, parseXmlPlist, toXmlPlist } from '../cli/lib/plist'; @@ -269,8 +269,10 @@ describe('gradle projects', () => { touch(rn, 'android/settings.gradle'); touch(rn, 'android/gradlew', '#!/bin/sh'); touch(rn, 'android/app/build.gradle'); const t = pickGradle(rn); expect(t).toMatchObject({ framework: 'react-native', module: 'app', task: ':app:bundleRelease' }); - expect(gradleArgs(t, { versionCode: 7, versionName: '1.2.0', hasWrapper: true })).toEqual(['./gradlew', ':app:bundleRelease', '--no-daemon', '--console=plain', + expect(gradleArgs(t, { versionCode: 7, versionName: '1.2.0', hasWrapper: true }, 'linux')).toEqual(['./gradlew', ':app:bundleRelease', '--no-daemon', '--console=plain', '-Pandroid.injected.version.code=7', '-PversionCode=7', '-Pandroid.injected.version.name=1.2.0', '-PversionName=1.2.0']); + // Windows: a bare gradlew.bat is looked up on PATH only; cmd /c runs the one in the project folder + expect(gradleArgs(t, { hasWrapper: true }, 'win32').slice(0, 4)).toEqual(['cmd', '/c', 'gradlew.bat', ':app:bundleRelease']); expect(pickGradle(rn, { flavor: 'prod' }).task).toBe(':app:bundleProdRelease'); const unity = dir('unity'); touch(unity, 'Export/settings.gradle'); touch(unity, 'Export/launcher/build.gradle'); touch(unity, 'Export/unityLibrary/build.gradle'); @@ -360,12 +362,26 @@ describe('credentials for builds', () => { delete process.env.ANDROID_KEYSTORE; expect(androidSigning(d)).toEqual({ storeFile: join(d, 'keys/upload.jks'), storePassword: 'pw', keyAlias: 'upload', keyPassword: 'pw' }); expect(androidSigning(dir('nocreds'))).toBeNull(); + // a Windows absolute path is used as it is, not joined onto the project folder + if (process.platform === 'win32') { + const w = dir('wincreds'); + touch(w, '.storebound/credentials.json', JSON.stringify({ ANDROID_KEYSTORE: 'C:/keys/upload.jks', ANDROID_KEYSTORE_PASSWORD: 'pw', ANDROID_KEY_ALIAS: 'upload' })); + expect(androidSigning(w)?.storeFile).toBe('C:/keys/upload.jks'); + } + // another machine's paths and a keystore the stored password does not open are named by field + const m = dir('moved'); + touch(m, 'keys/upload.jks', 'x'); + touch(m, '.storebound/credentials.json', JSON.stringify({ ANDROID_KEYSTORE: 'keys/upload.jks', ANDROID_KEYSTORE_PASSWORD: 'wrong', ANDROID_KEY_ALIAS: 'upload', PLAY_SERVICE_ACCOUNT: '/Users/someone/keys/play.json' })); + const issues = credentialFileIssues(m, () => ({ ok: false, out: 'keytool error: java.io.IOException: Keystore was tampered with, or password was incorrect' })); + expect(issues.some(i => i.startsWith('PLAY_SERVICE_ACCOUNT: /Users/someone/keys/play.json does not exist'))).toBe(true); + expect(issues.some(i => i.startsWith('ANDROID_KEYSTORE_PASSWORD does not open'))).toBe(true); + expect(credentialFileIssues(m, () => ({ ok: true, out: '' })).some(i => i.startsWith('ANDROID'))).toBe(false); const { pem } = newSigningKey(); const p8 = createPrivateKey(pem).export({ type: 'pkcs8', format: 'pem' }).toString(); Object.assign(process.env, { ASC_KEY_ID: 'KEYID00001', ASC_ISSUER_ID: '00000000-0000-0000-0000-000000000000', ASC_P8: p8 }); const k = ascKeyFile(d); expect(readFileSync(k.keyPath, 'utf8')).toBe(p8); - expect(k.keyPath.split('/').pop()).toBe(['AuthKey', 'KEYID00001.p8'].join('_')); + expect(k.keyPath.split(/[\\/]/).pop()).toBe(['AuthKey', 'KEYID00001.p8'].join('_')); k.cleanup(); expect(() => readFileSync(k.keyPath)).toThrow(); } finally { for (const key of ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8']) if (saved[key] === undefined) delete process.env[key]; else process.env[key] = saved[key]; }