diff --git a/CHANGELOG.md b/CHANGELOG.md index ce895b9..7e71c73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- **CI-friendly Play output.** `storebound aab --json` reads packageName, versionCode, versionName, SDKs and + sha256 offline (no config, no upload); `play release ... --json` prints {versionCode, sha256, name, tracks[{track, + status, userFraction}]}. `play status --json` already lists each track's releases. + - **`apple build` stops at once over SSH** (codesign fails there with errSecInternalComponent) and names how to run it in the logged-in session; relative `ASC_P8` / `PLAY_SERVICE_ACCOUNT` paths are read from the project folder and xcodebuild gets an absolute key path. Pitfall 101. diff --git a/cli/aab.ts b/cli/aab.ts new file mode 100644 index 0000000..c4ef0eb --- /dev/null +++ b/cli/aab.ts @@ -0,0 +1,18 @@ +// Offline: what an Android App Bundle says about itself, without a config or credentials (CI reads the version code +// before it uploads, instead of unzipping the AAB or asking the Play API). +// storebound aab [--json] +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { Args } from './lib/args'; +import { aabInfo } from './lib/gradle'; +import { log, printJson } from './lib/log'; + +export async function run(args: Args): Promise { + const file = args._.find(x => x.endsWith('.aab')); + if (!file) throw new Error('usage: storebound aab [--json]'); + const data = readFileSync(resolve(file)); + const info = { ...aabInfo(data), sha256: createHash('sha256').update(data).digest('hex'), bytes: data.length }; + if (args.has('json')) { printJson(info); return; } + log('aab', `${info.packageName} ${info.versionName ?? '?'} (${info.versionCode}) · minSdk ${info.minSdk ?? '?'} · targetSdk ${info.targetSdk ?? '?'} · ${(info.bytes / 1e6).toFixed(1)} MB`); +} diff --git a/cli/commands.ts b/cli/commands.ts index 789219c..5b0f6ae 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -16,6 +16,7 @@ export const PLAIN: Record Promise, string]> = { 'desktop': [() => import('./desktop'), 'open the storebound desktop app on this folder; not installed or another version: download it for this system (size, SHA-256, signature checked), install it for this user (asks once; --yes) and open it; --download only saves the installer, --dry / --json change nothing'], 'accounts': [() => import('./accounts'), 'the store accounts of this machine (credential profiles in ~/.storebound/credentials, shared with the desktop app): list | import (a project\'s store keys -> a profile; --dry first) | use | default (the machine default; --clear)'], 'settings': [() => import('./settings'), 'per-machine settings: bypass on | off | status (owner opt-in: the agent also presses the owner-only buttons; default off)'], + 'aab': [() => import('./aab'), 'offline: an AAB file: packageName, versionCode, versionName, min / target SDK, sha256 (no config, no upload); --json'], 'doctor': [() => import('./doctor'), 'build and capture dependencies (JDK with the version the project needs, Android SDK, Xcode, CocoaPods, Node, Bun, ffmpeg, Chrome, adb, git, bundletool) and how to install what is missing; --install --yes; --json'], 'scan': [() => import('./scan'), 'offline, read-only rejection-risk scan of the project itself (stack, SDKs, Info.plist, manifest, listings) with guideline, evidence file:line, fix and skill, plus the Data safety / App Privacy / ATT / privacy manifest proposal; --write puts the detected answers into the config; --json; exit 1 on blockers'], 'api': [() => import('./api'), 'every App Store Connect / Google Play API operation: list | coverage | apple|play [--param k=v] [--body f.json] [--all-pages] (GET runs; writes dry unless --yes in a terminal; owner-only ones need the id typed)'], diff --git a/cli/data/flags.json b/cli/data/flags.json index a24b5e2..e27b0da 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -11,6 +11,7 @@ "yes" ], "commands": { + "aab": [], "accounts": [ "clear", "dir", @@ -1178,6 +1179,9 @@ ] }, "usage": { + "aab": [ + "storebound aab [--json]" + ], "accounts": [ "storebound accounts [list] [--json] every profile: Apple key, Play account (masked), which", "storebound accounts import [--dir ] [--name ] [--dry] [--yes]", diff --git a/cli/lib/desktop-coverage.ts b/cli/lib/desktop-coverage.ts index e127b05..8628439 100644 --- a/cli/lib/desktop-coverage.ts +++ b/cli/lib/desktop-coverage.ts @@ -46,6 +46,7 @@ export const DESKTOP_COVERAGE: Record = { 'import': { where: 'App folders / first-run guide: Import from fastlane', methods: ['host:project_import_fastlane'] }, 'install': { where: `${T}: Install for an AI tool`, methods: ['agent.install'] }, 'mcp': { not: 'the stdio server for AI tools; the window itself is the GUI over the same commands' }, + 'aab': { not: 'a CI helper that reads one AAB file; the window shows the builds each store has' }, 'settings': { not: 'per-machine switch for AI agents (bypass mode); in the window the owner presses owner actions in a native dialog anyway' }, 'serve': { not: 'the JSON-RPC sidecar the window runs on; nothing to click' }, 'apps': { where: 'Accounts (store apps per account, key check) and App folders (list, add, remove)', methods: ['host:store_apps_refresh', 'host:account_check', 'host:projects_list', 'host:project_add', 'host:project_remove'] }, diff --git a/cli/play/release.ts b/cli/play/release.ts index 808214a..24c3d8f 100644 --- a/cli/play/release.ts +++ b/cli/play/release.ts @@ -8,9 +8,10 @@ // Draft app (never published): Play only accepts status "draft" on any track; the first production release is sent // from the Console (see the play-first-release skill). import { readFileSync, statSync } from 'node:fs'; +import { createHash } from 'node:crypto'; import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; -import { done, log, warn } from '../lib/log'; +import { done, log, warn, printJson } from '../lib/log'; import { aabInfo } from '../lib/gradle'; import { playTargetSdkProblem, targetSdkFile } from '../lib/store-minimums'; import { playClient } from './client'; @@ -55,6 +56,7 @@ export async function run(ctx: Ctx): Promise { if (sdkWhy) throw new Error(`${aab}: not released, ${sdkWhy}`); } const play = await playClient(root, pkg); + let released = fixed ?? ''; try { await play.edit(async (id, ctl) => { let code = fixed ?? ''; @@ -74,6 +76,7 @@ export async function run(ctx: Ctx): Promise { log('play', `uploaded versionCode ${code}`); } } + released = code; let changed = 0; for (const t of tracks) { const release: PlayTrackRelease = { versionCodes: [code], status: t.status }; @@ -100,5 +103,8 @@ export async function run(ctx: Ctx): Promise { if (config.play?.managedPublishing !== true) warn('play', `managed publishing is ${config.play?.managedPublishing === false ? 'off' : 'off by default (play.managedPublishing not set)'}: the release goes live automatically once Google approves it`); if (prod.status === 'draft') log('play', 'next: `storebound console plan play-production-countries` (the API cannot set countries), then `storebound console plan play-production-release` (its final "Send changes for review" is the owner\'s)'); } + // machine-readable result for CI scripts (a Jenkins stage reads it instead of calling the Play API itself) + if (a.has('json')) printJson({ versionCode: released ? Number(released) : null, sha256: aab ? createHash('sha256').update(readFileSync(aab)).digest('hex') : null, name: a.get('name') ?? null, + tracks: tracks.map(t => ({ track: t.track, status: t.status, userFraction: t.status === 'inProgress' ? Number(fraction ?? 0.1) : null })), dry: a.has('dry') }); done('play', 'release done'); } diff --git a/docs/desktop-coverage.md b/docs/desktop-coverage.md index 73c9dde..ae4a1af 100644 --- a/docs/desktop-coverage.md +++ b/docs/desktop-coverage.md @@ -5,7 +5,7 @@ Every CLI command (cli/commands.ts) and the desktop place that runs it. "covered (serve.ts, or a host command `host:`) and the window calls it; tests/desktop-coverage.test.ts fails on a gap. Owner-only parts are owner actions in the window or owner steps with their terminal command (More tools). -**96 commands:** 91 covered, 0 gaps, 5 not for the desktop. +**97 commands:** 91 covered, 0 gaps, 6 not for the desktop. | command | status | where in the window | methods | owner step | |---|---|---|---|---| @@ -18,6 +18,7 @@ Owner-only parts are owner actions in the window or owner steps with their termi | `desktop` | not-for-desktop | opens or installs this window from a terminal | | | | `accounts` | covered | Accounts screen (profiles shared with the CLI) | `host:accounts_list`, `host:account_add_play`, `host:account_add_apple`, `host:account_update` | | | `settings` | not-for-desktop | per-machine switch for AI agents (bypass mode); in the window the owner presses owner actions in a native dialog anyway | | | +| `aab` | not-for-desktop | a CI helper that reads one AAB file; the window shows the builds each store has | | | | `doctor` | covered | Dependencies screen | `system.tools`, `host:tools_info` | | | `scan` | covered | Checks tab: Scan, Fix | `scan`, `scan.write` | | | `api` | not-for-desktop | raw API calls by operation id: every task has its own screen, and raw writes stay a terminal step the owner types | | | diff --git a/tests/build-sign.test.ts b/tests/build-sign.test.ts index 843df9f..a397a99 100644 --- a/tests/build-sign.test.ts +++ b/tests/build-sign.test.ts @@ -515,3 +515,12 @@ describe('apple build outside the GUI session (pitfall 101)', () => { } finally { process.env = saved; } }); }); + +describe('storebound aab (CI reads the version code without uploading)', () => { + test('prints packageName, versionCode, versionName as JSON', () => { + const d = dir('inspect-aab'); + writeFileSync(join(d, 'app.aab'), makeAab('com.example.game', 4321, '3.1.0')); + const r = Bun.spawnSync(['bun', join(import.meta.dir, '..', 'cli', 'index.ts'), 'aab', 'app.aab', '--json'], { cwd: d, stdout: 'pipe', stderr: 'pipe' }); + expect(JSON.parse(r.stdout.toString())).toMatchObject({ packageName: 'com.example.game', versionCode: 4321, versionName: '3.1.0' }); + }); +}); diff --git a/tests/desktop-coverage.test.ts b/tests/desktop-coverage.test.ts index cef5fcf..c12236b 100644 --- a/tests/desktop-coverage.test.ts +++ b/tests/desktop-coverage.test.ts @@ -8,7 +8,7 @@ import { describeMethods } from '../cli/serve'; import { analyze, render } from '../tools/gen-desktop-coverage'; /** The only commands that make no sense in a window. Adding one here needs a reason in the coverage map. */ -const NOT_FOR_DESKTOP = ['mcp', 'serve', 'desktop', 'api', 'settings']; +const NOT_FOR_DESKTOP = ['mcp', 'serve', 'desktop', 'api', 'settings', 'aab']; describe('desktop coverage', () => { const { rows, extra } = analyze();