storebound aab <file> --json (offline packageName / versionCode / versionName / SDKs / sha256, no config) and play release --json result for CI scripts that replace their own Play API calls

This commit is contained in:
CRTkafa 2026-10-22 03:28:46 +08:00
parent ee35b11341
commit 256e8e33ad
9 changed files with 47 additions and 3 deletions

18
cli/aab.ts Normal file
View file

@ -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 <file.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<void> {
const file = args._.find(x => x.endsWith('.aab'));
if (!file) throw new Error('usage: storebound aab <file.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`);
}

View file

@ -16,6 +16,7 @@ export const PLAIN: Record<string, [() => Promise<Plain>, 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 <profile> | default <profile> (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 <tool> --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 <operationId|METHOD path> [--param k=v] [--body f.json] [--all-pages] (GET runs; writes dry unless --yes in a terminal; owner-only ones need the id typed)'],

View file

@ -11,6 +11,7 @@
"yes"
],
"commands": {
"aab": [],
"accounts": [
"clear",
"dir",
@ -1178,6 +1179,9 @@
]
},
"usage": {
"aab": [
"storebound aab <file.aab> [--json]"
],
"accounts": [
"storebound accounts [list] [--json] every profile: Apple key, Play account (masked), which",
"storebound accounts import [--dir <project>] [--name <profile>] [--dry] [--yes]",

View file

@ -46,6 +46,7 @@ export const DESKTOP_COVERAGE: Record<string, Coverage> = {
'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'] },

View file

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