build.prebuild and a Capacitor web-asset staleness guard for apple build / play build

apple build and play build run build.prebuild (or apple.prebuild / play.prebuild) first, once per flow run;
--no-prebuild skips it, --dry only prints it. A Capacitor project's copied public/index.html must then be newer
than the last source commit and <webDir>/index.html, else the build stops (--allow-stale-web overrides). check
warns when stale without a prebuild; the build output and dry plan show the web bundle's sha256 and mtime.
The Capacitor project is also found in a monorepo (apps/native etc.); several ask for play.build.dir /
apple.build.*. Pitfall 23.
This commit is contained in:
CRTkafa 2026-10-12 08:44:05 +08:00
parent f694af80eb
commit 5fbb9e63e3
14 changed files with 600 additions and 25 deletions

View file

@ -2,6 +2,21 @@
## Unreleased ## Unreleased
- Fix (`apple build`, `play build`, Capacitor): a native build no longer ships old web assets silently. New
`build.prebuild` (per store `apple.prebuild` / `play.prebuild`): a shell command run in the project root before the
archive / bundle (e.g. `npm run build && npx cap sync`; printed, output streamed, nonzero exit stops the build,
`--dry` only prints it, `--no-prebuild` skips it, once per `flow run` instead of once per store). After it, the
copied `ios/App/App/public/index.html` / `android/app/src/main/assets/public/index.html` must be newer than the last
git commit outside ios/ android/ node_modules/ store/ .storebound/ and `<webDir>/index.html`, else the build stops
with "web assets are older than the source (copied ..., source ...)"; `--allow-stale-web` overrides. `check` warns
when they are stale and no prebuild is set. The build output and the dry-run plan (`web` in `--json`) show the web
bundle that went in: sha256 (12 hex) and mtime of the copied index.html. Pitfall 23.
- Monorepos: `play build` failed with "no Gradle project (settings.gradle) in android or ." when the Capacitor app
lives in `apps/native` (capacitor.config.ts, android/, ios/ there). `apple build`, `play build`, the staleness
check and `check` now use the root's capacitor.config.*, else the one found one or two levels down (package.json
workspaces, apps/*, packages/*, direct child folders; node_modules skipped) and name it ("Capacitor project:
apps/native"). Several: they are listed and `play.build.dir` / `apple.build.workspace|project` is asked for; an
explicit config value always wins.
- Fix (`play build`, `play upload`, `play release`): real AGP bundles (e.g. Capacitor 8) no longer fail with "AAB - Fix (`play build`, `play upload`, `play release`): real AGP bundles (e.g. Capacitor 8) no longer fail with "AAB
manifest without package / versionCode". The aapt2 proto manifest is read with Resources.proto's field numbers. manifest without package / versionCode". The aapt2 proto manifest is read with Resources.proto's field numbers.
Pitfall 22. Pitfall 22.

View file

@ -346,7 +346,7 @@ run in the background with a log (`storebound shots render > .storebound/render.
| `play products` | API | one-time products: regional prices from `convertRegionPrices` + your overrides, activate | | `play products` | API | one-time products: regional prices from `convertRegionPrices` + your overrides, activate |
| `play subscriptions [--csv]` | API | subscriptions: listings + benefits, one auto-renewing base plan, regional prices, intro and extra offers, activate (plan first, asks) | | `play subscriptions [--csv]` | API | subscriptions: listings + benefits, one auto-renewing base plan, regional prices, intro and extra offers, activate (plan first, asks) |
| `play data-safety` | API | Data safety form (CSV import); `--csv` writes the file | | `play data-safety` | API | Data safety form (CSV import); `--csv` writes the file |
| `play build [--version-code N] [--flavor f]` | local + API read | Gradle `bundleRelease` (Flutter `flutter build appbundle`; Capacitor, React Native, Unity export found) with the next free versionCode and the upload key from env / `.storebound` (passed through the environment, never argv); AAB checked and copied to `build/android/` | | `play build [--version-code N] [--flavor f]` | local + API read | `build.prebuild` first (Capacitor: web build + cap sync; stale copied web assets stop the build), then Gradle `bundleRelease` (Flutter `flutter build appbundle`; Capacitor, React Native, Unity export found) with the next free versionCode and the upload key from env / `.storebound` (passed through the environment, never argv); AAB checked and copied to `build/android/` |
| `play upload [<aab>] --track internal\|alpha\|beta` | API | AAB to a testing track with release notes; versionCode checked first; `--dry` is read-only, a real run asks | | `play upload [<aab>] --track internal\|alpha\|beta` | API | AAB to a testing track with release notes; versionCode checked first; `--dry` is read-only, a real run asks |
| `play release <aab>` | API | upload + tracks (`--internal completed --production draft`, `--production inProgress --fraction 0.05`), release notes | | `play release <aab>` | API | upload + tracks (`--internal completed --production draft`, `--production inProgress --fraction 0.05`), release notes |
| `play rollout` | API | `--fraction x`, `--halt`, `--resume`, `--complete` (asks first, except halt) | | `play rollout` | API | `--fraction x`, `--halt`, `--resume`, `--complete` (asks first, except halt) |
@ -380,7 +380,7 @@ run in the background with a log (`storebound shots render > .storebound/render.
| `apple release --phased on\|pause\|resume\|complete`, `--manual-release` | API | phased release, release an approved version (resume / complete / release ask first) | | `apple release --phased on\|pause\|resume\|complete`, `--manual-release` | API | phased release, release an approved version (resume / complete / release ask first) |
| `apple signing status` | API, read-only | bundle id capabilities, distribution certificates (with / without a private key on this Mac), App Store profiles per bundle id, installed or not | | `apple signing status` | API, read-only | bundle id capabilities, distribution certificates (with / without a private key on this Mac), App Store profiles per bundle id, installed or not |
| `apple signing ensure [--keychain] [--no-install]` | API | missing capabilities, a distribution certificate from a key made here (kept 0600 in `.storebound/signing/`, never printed), App Store profiles installed for Xcode; dry first, asks; Developer ID needs the Account Holder (403 explained) | | `apple signing ensure [--keychain] [--no-install]` | API | missing capabilities, a distribution certificate from a key made here (kept 0600 in `.storebound/signing/`, never printed), App Store profiles installed for Xcode; dry first, asks; Developer ID needs the Account Holder (403 explained) |
| `apple build [--version x.y.z] [--build-number N]` | local + API read | xcodebuild archive + exportArchive with a generated ExportOptions.plist (app-store-connect, team, signing style, profiles) and the API key; next build number; Capacitor, React Native, Flutter, Unity projects found | | `apple build [--version x.y.z] [--build-number N]` | local + API read | `build.prebuild` first (Capacitor: web build + cap sync; stale copied web assets stop the build), then xcodebuild archive + exportArchive with a generated ExportOptions.plist (app-store-connect, team, signing style, profiles) and the API key; next build number; Capacitor, React Native, Flutter, Unity projects found |
| `apple upload [<ipa>] [--via api\|altool]` | API | IPA through the App Store Connect API build upload (or altool), build number checked, waits until VALID (exit 0 / 1 / 7); dry first, asks | | `apple upload [<ipa>] [--via api\|altool]` | API | IPA through the App Store Connect API build upload (or altool), build number checked, waits until VALID (exit 0 / 1 / 7); dry first, asks |
| `apple next-build` | API, read-only | next safe build number | | `apple next-build` | API, read-only | next safe build number |
| `apple status` | API | versions, builds, IAPs, submissions; `--wait-review` (exit 0 / 1 / 7); `--cancel-submission <id>` | | `apple status` | API | versions, builds, IAPs, submissions; `--wait-review` (exit 0 / 1 / 7); `--cancel-submission <id>` |

View file

@ -1,6 +1,8 @@
// Archive and export an App Store IPA with xcodebuild, no Xcode UI. // Archive and export an App Store IPA with xcodebuild, no Xcode UI.
// storebound apple build [--version 1.2.0] [--build-number N] [--scheme S] [--workspace W | --project P] // storebound apple build [--version 1.2.0] [--build-number N] [--scheme S] [--workspace W | --project P]
// [--configuration Release] [--out build/ios] [--dry] [--json] // [--configuration Release] [--out build/ios] [--no-prebuild] [--allow-stale-web] [--dry] [--json]
// First the prebuild (build.prebuild / apple.prebuild: e.g. the web build + npx cap sync; once per flow run), then a
// Capacitor project's copied web assets (ios/App/App/public) must be newer than the source, else it stops.
// Finds the workspace / project and scheme (native, Capacitor ios/App, React Native ios/<Name>, Flutter ios/Runner, // Finds the workspace / project and scheme (native, Capacitor ios/App, React Native ios/<Name>, Flutter ios/Runner,
// Unity Unity-iPhone), takes the next safe build number from App Store Connect (apple next-build) unless // Unity Unity-iPhone), takes the next safe build number from App Store Connect (apple next-build) unless
// --build-number is given, writes <out>/ExportOptions.plist (method app-store-connect, team, signing style and the // --build-number is given, writes <out>/ExportOptions.plist (method app-store-connect, team, signing style and the
@ -10,7 +12,7 @@
// Local only: nothing is sent to App Store Connect except reads (build numbers, team id, profiles). // Local only: nothing is sent to App Store Connect except reads (build numbers, team id, profiles).
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { join, relative } from 'node:path'; import { dirname, join, relative } from 'node:path';
import { ascKeyFile } from '../lib/credentials'; import { ascKeyFile } from '../lib/credentials';
import type { Ctx } from '../lib/ctx'; import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx'; import { need } from '../lib/ctx';
@ -19,6 +21,7 @@ import { pump } from '../lib/runner';
import { parseMobileprovision, toXmlPlist } from '../lib/plist'; import { parseMobileprovision, toXmlPlist } from '../lib/plist';
import { nextBuildNumber } from '../lib/release'; import { nextBuildNumber } from '../lib/release';
import { exportOptions, profileDirs } from '../lib/signing'; import { exportOptions, profileDirs } from '../lib/signing';
import { capacitorProject, webStep } from '../lib/web-assets';
import { archiveArgs, type AscAuth, exportArgs, flutterConfigArgs, ipaInfo, pickXcode, scanXcode, showCmd } from '../lib/xcode'; import { archiveArgs, type AscAuth, exportArgs, flutterConfigArgs, ipaInfo, pickXcode, scanXcode, showCmd } from '../lib/xcode';
import { ascClient } from './asc'; import { ascClient } from './asc';
import { findBundleId } from './signing'; import { findBundleId } from './signing';
@ -69,7 +72,14 @@ export async function run(ctx: Ctx): Promise<void> {
const style = signing.style ?? 'automatic'; const style = signing.style ?? 'automatic';
const dry = a.has('dry'); const dry = a.has('dry');
const json = a.has('json'); const json = a.has('json');
const target = pickXcode(scanXcode(root), { ...cfg, workspace: a.get('workspace') ?? cfg.workspace, project: a.get('project') ?? cfg.project, scheme: a.get('scheme') ?? cfg.scheme }); const workspace = a.get('workspace') ?? cfg.workspace, project = a.get('project') ?? cfg.project;
// Capacitor in a monorepo (apps/native/ios): the config's workspace / project wins, else the one Capacitor project found
const explicit = workspace ?? project;
const cap = capacitorProject(root, explicit ? dirname(explicit) : undefined);
const sub = !explicit && cap?.dir ? cap.dir : '';
const found = pickXcode(scanXcode(sub ? join(root, sub) : root), { ...cfg, workspace, project, scheme: a.get('scheme') ?? cfg.scheme });
const target = sub ? { ...found, ...(found.workspace ? { workspace: `${sub}/${found.workspace}` } : {}), ...(found.project ? { project: `${sub}/${found.project}` } : {}) } : found;
if (sub) target.notes.unshift(`Capacitor project: ${sub} (${sub}/${cap!.config}; the only one found; set apple.build.workspace to use another)`);
const configuration = a.str('configuration', cfg.configuration ?? 'Release'); const configuration = a.str('configuration', cfg.configuration ?? 'Release');
const out = resolve(a.get('out') ?? cfg.out ?? 'build/ios'); const out = resolve(a.get('out') ?? cfg.out ?? 'build/ios');
const version = a.get('version'); const version = a.get('version');
@ -115,8 +125,10 @@ export async function run(ctx: Ctx): Promise<void> {
]; ];
const plan = { framework: target.framework, workspace: target.workspace ?? null, project: target.project ?? null, scheme: target.scheme, configuration, const plan = { framework: target.framework, workspace: target.workspace ?? null, project: target.project ?? null, scheme: target.scheme, configuration,
version: version ?? '(project value)', build: buildNo, teamId, signingStyle: style, exportOptions: opts, out: relative(root, out) || '.', steps, notes: target.notes }; version: version ?? '(project value)', build: buildNo, teamId, signingStyle: style, exportOptions: opts, out: relative(root, out) || '.', steps, notes: target.notes };
const webOpts = { config, root, cap, store: 'apple' as const, tag: TAG, noPrebuild: a.has('no-prebuild'), allowStale: a.has('allow-stale-web') };
if (dry) { if (dry) {
if (json) { printJson(plan); return; } const web = await webStep({ ...webOpts, dry: true, quiet: json });
if (json) { printJson({ ...plan, web }); return; }
log(TAG, `${target.framework}: ${target.workspace ?? target.project} scheme ${target.scheme} (${configuration}), build ${buildNo}${version ? `, version ${version}` : ''}, signing ${style}`); log(TAG, `${target.framework}: ${target.workspace ?? target.project} scheme ${target.scheme} (${configuration}), build ${buildNo}${version ? `, version ${version}` : ''}, signing ${style}`);
for (const n of target.notes) log(TAG, n); for (const n of target.notes) log(TAG, n);
for (const s of steps) log(TAG, `would run: ${s}`); for (const s of steps) log(TAG, `would run: ${s}`);
@ -125,6 +137,7 @@ export async function run(ctx: Ctx): Promise<void> {
return; return;
} }
if (process.platform !== 'darwin' || !Bun.which('xcodebuild')) throw new Error('apple build needs macOS with Xcode (xcode-select --install is not enough: the full Xcode app)'); if (process.platform !== 'darwin' || !Bun.which('xcodebuild')) throw new Error('apple build needs macOS with Xcode (xcode-select --install is not enough: the full Xcode app)');
const web = await webStep({ ...webOpts, dry: false });
for (const n of target.notes) log(TAG, n); for (const n of target.notes) log(TAG, n);
mkdirSync(out, { recursive: true }); mkdirSync(out, { recursive: true });
writeFileSync(join(out, 'ExportOptions.plist'), plist); writeFileSync(join(out, 'ExportOptions.plist'), plist);
@ -142,7 +155,7 @@ export async function run(ctx: Ctx): Promise<void> {
const info = ipaInfo(join(exportPath, ipa)); const info = ipaInfo(join(exportPath, ipa));
if (info.bundleId !== bundleId) warn(TAG, `the IPA's bundle id is ${info.bundleId}, the config says ${bundleId}`); if (info.bundleId !== bundleId) warn(TAG, `the IPA's bundle id is ${info.bundleId}, the config says ${bundleId}`);
if (info.build !== build) warn(TAG, `the IPA has build ${info.build}, not ${build}: the project sets CFBundleVersion itself (use $(CURRENT_PROJECT_VERSION) in Info.plist)`); if (info.build !== build) warn(TAG, `the IPA has build ${info.build}, not ${build}: the project sets CFBundleVersion itself (use $(CURRENT_PROJECT_VERSION) in Info.plist)`);
const result = { ipa: join(exportPath, ipa), bundleId: info.bundleId, version: info.version, build: info.build }; const result = { ipa: join(exportPath, ipa), bundleId: info.bundleId, version: info.version, build: info.build, web: web.bundle };
if (json) printJson(result); 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`); done(TAG, `${relative(root, result.ipa)}: ${info.bundleId} ${info.version} (${info.build}). Next: storebound apple upload ${relative(root, result.ipa)} --dry`);
} }

View file

@ -5,7 +5,7 @@
// storebound check --aso keyword audit of both listing files (App Store keyword field, Play stuffing); exit 1 on errors // storebound check --aso keyword audit of both listing files (App Store keyword field, Play stuffing); exit 1 on errors
// --json structured result ({ ok, items, warnings, hints, problems } or { findings, errors }), exit 1 on problems // --json structured result ({ ok, items, warnings, hints, problems } or { findings, errors }), exit 1 on problems
import { existsSync, readFileSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';
import { relative } from 'node:path'; import { dirname, relative } from 'node:path';
import { appPrivacyMismatches } from './lib/app-privacy'; import { appPrivacyMismatches } from './lib/app-privacy';
import { type AsoFinding, auditAso } from './lib/aso'; import { type AsoFinding, auditAso } from './lib/aso';
import { draftLocales } from './lib/aso-intel'; import { draftLocales } from './lib/aso-intel';
@ -20,6 +20,7 @@ import { detectProject } from './lib/scan/project';
import { reviewShotProblem } from './apple/iap-review'; import { reviewShotProblem } from './apple/iap-review';
import { allSubscriptions } from './lib/subscriptions'; import { allSubscriptions } from './lib/subscriptions';
import { type CopyFile, validateCopy } from './shots/lib'; import { type CopyFile, validateCopy } from './shots/lib';
import { type CapProject, capacitorProject, prebuildFor, staleMessage, webState } from './lib/web-assets';
function aso(ctx: Ctx): void { function aso(ctx: Ctx): void {
const { config, resolve } = ctx.loaded; const { config, resolve } = ctx.loaded;
@ -119,6 +120,16 @@ export async function run(ctx: Ctx): Promise<void> {
if (e.length) throw new Error(e.join('; ')); if (e.length) throw new Error(e.join('; '));
return `${Object.keys(copy).length} languages x ${config.shots!.scenes.length} scenes`; return `${Object.keys(copy).length} languages x ${config.shots!.scenes.length} scenes`;
}); });
// Capacitor: the native builds ship the web assets cap sync copied last (pitfall 20)
for (const store of ['apple', 'play'] as const) {
if (!config[store] || prebuildFor(config, store)) continue;
const b = store === 'play' ? config.play?.build?.dir : config.apple?.build?.workspace ?? config.apple?.build?.project;
let cap: CapProject | null = null;
try { cap = capacitorProject(ctx.loaded.root, b && store === 'apple' ? dirname(b) : b); } catch (e) { note(`${store}: ${(e as Error).message}`); continue; }
if (!cap) continue;
const st = webState(ctx.loaded.root, store, cap);
if (st.staleness.stale) note(`${store}: ${staleMessage(st.path, st.staleness, st.copied)}. Set build.prebuild (e.g. "npm run build && npx cap sync") so apple build / play build refresh them`);
}
for (const i of profileIssues(config,{ demoAccount: !!reviewDemoAccount(ctx.loaded.root) })) { for (const i of profileIssues(config,{ demoAccount: !!reviewDemoAccount(ctx.loaded.root) })) {
if (i.level === 'error') problems.push(i.msg); else warn('check', i.msg); if (i.level === 'error') problems.push(i.msg); else warn('check', i.msg);
} }

View file

@ -56,10 +56,12 @@
], ],
"apple appinfo": [], "apple appinfo": [],
"apple build": [ "apple build": [
"allow-stale-web",
"build-number", "build-number",
"configuration", "configuration",
"keychain", "keychain",
"no-install", "no-install",
"no-prebuild",
"out", "out",
"project", "project",
"scheme", "scheme",
@ -379,9 +381,11 @@
"threshold" "threshold"
], ],
"play build": [ "play build": [
"allow-stale-web",
"dir", "dir",
"flavor", "flavor",
"module", "module",
"no-prebuild",
"out", "out",
"version-code", "version-code",
"version-name" "version-name"
@ -655,6 +659,7 @@
"agent", "agent",
"all", "all",
"all-pages", "all-pages",
"allow-stale-web",
"alpha", "alpha",
"apk", "apk",
"app", "app",
@ -759,6 +764,7 @@
"no-install", "no-install",
"no-local", "no-local",
"no-mcp", "no-mcp",
"no-prebuild",
"no-save", "no-save",
"no-scan", "no-scan",
"no-sheet", "no-sheet",

View file

@ -10,16 +10,19 @@
// the last run did not finish (.storebound/flows/<name>.json) // the last run did not finish (.storebound/flows/<name>.json)
// Progress: "[flow] 3/9 upload: storebound apple upload --no-wait --dry" before each step, the step's own output // Progress: "[flow] 3/9 upload: storebound apple upload --no-wait --dry" before each step, the step's own output
// indented, then ok / failed with the time. Exit 1 when a step failed (and had no continueOnError). // indented, then ok / failed with the time. Exit 1 when a step failed (and had no continueOnError).
// build.prebuild runs once per run: after a real apple build / play build step, its prebuild command goes to the later
// steps in STOREBOUND_PREBUILD_DONE and they skip it.
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path'; import { dirname, join, relative } from 'node:path';
import type { Args } from './lib/args'; import type { Args } from './lib/args';
import { CONFIG_NAMES, loadConfig } from './lib/config'; import { CONFIG_NAMES, loadConfig, type ShipConfig } from './lib/config';
import { import {
type CondCtx, expand, type Flow, flowsOfFile, kindOf, type Mode, parseParams, planStep, type PlannedStep, resumePoint, type RunState, startIndex, type CondCtx, expand, type Flow, flowsOfFile, kindOf, type Mode, parseParams, planStep, type PlannedStep, resumePoint, type RunState, startIndex,
type StepResult, validateFlows, type StepResult, validateFlows,
} from './lib/flow'; } from './lib/flow';
import { done, log, printJson, step, warn } from './lib/log'; import { done, log, printJson, step, warn } from './lib/log';
import { childEnv, pump, selfCommand } from './lib/runner'; import { childEnv, pump, selfCommand } from './lib/runner';
import { PREBUILD_DONE_ENV, prebuildFor, prebuildsDone } from './lib/web-assets';
import { parseYaml } from './lib/yaml'; import { parseYaml } from './lib/yaml';
const TAG = 'flow'; const TAG = 'flow';
@ -68,10 +71,18 @@ function readState(root: string, name: string): RunState | null {
try { return JSON.parse(readFileSync(statePath(root, name), 'utf8')) as RunState; } catch { return null; } try { return JSON.parse(readFileSync(statePath(root, name), 'utf8')) as RunState; } catch { return null; }
} }
async function condCtx(root: string): Promise<CondCtx> { async function condCtx(root: string): Promise<CondCtx & { config: ShipConfig | null }> {
let apple = false, play = false; let config: ShipConfig | null = null;
try { const { config } = await loadConfig(undefined, root); apple = !!config.apple; play = !!config.play; } catch { /* no config: store conditions are false */ } try { config = (await loadConfig(undefined, root)).config; } catch { /* no config: store conditions are false */ }
return { apple, play, exists: p => existsSync(join(root, p)) }; return { apple: !!config?.apple, play: !!config?.play, exists: p => existsSync(join(root, p)), config };
}
/** The prebuild a finished build step ran (apple build / play build, for real, without --no-prebuild), else null:
* later build steps of the same flow run skip that command (build.prebuild runs once per run, not per store). Pure. */
export function prebuildRan(argv: string[], config: ShipConfig | null): string | null {
if (!config || argv.includes('--dry') || argv.includes('--no-prebuild')) return null;
const store = argv[0] === 'apple' && argv[1] === 'build' ? 'apple' : argv[0] === 'play' && argv[1] === 'build' ? 'play' : null;
return store ? prebuildFor(config, store) ?? null : null;
} }
const KIND_LABEL: Record<string, string> = { read: 'read', write: 'write', wait: 'wait', owner: 'your click', shell: 'shell' }; const KIND_LABEL: Record<string, string> = { read: 'read', write: 'write', wait: 'wait', owner: 'your click', shell: 'shell' };
@ -83,9 +94,9 @@ function describe(p: PlannedStep): string {
} }
/** Runs one storebound step (or shell line) with stdin attached, so the command's own confirmation works; output indented. */ /** Runs one storebound step (or shell line) with stdin attached, so the command's own confirmation works; output indented. */
async function exec(p: PlannedStep, root: string, quiet: boolean): Promise<{ code: number; last: string; out: string }> { async function exec(p: PlannedStep, root: string, quiet: boolean, extraEnv: Record<string, string> = {}): Promise<{ code: number; last: string; out: string }> {
const cmd = p.shell !== undefined ? ['sh', '-c', p.shell] : [...selfCommand(), ...p.argv!]; const cmd = p.shell !== undefined ? ['sh', '-c', p.shell] : [...selfCommand(), ...p.argv!];
const proc = Bun.spawn(cmd, { cwd: root, stdin: 'inherit', stdout: 'pipe', stderr: 'pipe', env: childEnv() }); const proc = Bun.spawn(cmd, { cwd: root, stdin: 'inherit', stdout: 'pipe', stderr: 'pipe', env: childEnv(extraEnv) });
const lines: string[] = []; const lines: string[] = [];
let last = ''; let last = '';
const each = (line: string, kind: 'stdout' | 'stderr') => { const each = (line: string, kind: 'stdout' | 'stderr') => {
@ -147,6 +158,7 @@ async function runFlow(args: Args, root: string): Promise<void> {
const subParams = expand(flow, all, 'steps').params; const subParams = expand(flow, all, 'steps').params;
const vars: Record<string, string> = { ...subParams, ...flow.params, ...(from ? last?.vars ?? {} : {}), ...params }; const vars: Record<string, string> = { ...subParams, ...flow.params, ...(from ? last?.vars ?? {} : {}), ...params };
const c = await condCtx(root); const c = await condCtx(root);
const prebuilt = prebuildsDone();
const state: RunState = { flow: name, mode, startedAt: new Date().toISOString(), vars, results: [], ok: true }; const state: RunState = { flow: name, mode, startedAt: new Date().toISOString(), vars, results: [], ok: true };
const env = process.env as Record<string, string | undefined>; const env = process.env as Record<string, string | undefined>;
const save = () => { if (mode === 'plan') return; mkdirSync(dirname(statePath(root, name)), { recursive: true }); writeFileSync(statePath(root, name), JSON.stringify(state, null, 2) + '\n'); }; const save = () => { if (mode === 'plan') return; mkdirSync(dirname(statePath(root, name)), { recursive: true }); writeFileSync(statePath(root, name), JSON.stringify(state, null, 2) + '\n'); };
@ -174,10 +186,12 @@ async function runFlow(args: Args, root: string): Promise<void> {
} }
if (!json) log(TAG, `${label}: ${describe(p)}${p.dryAdded ? ' (dry run)' : ''}`); if (!json) log(TAG, `${label}: ${describe(p)}${p.dryAdded ? ' (dry run)' : ''}`);
const t = Date.now(); const t = Date.now();
const x = await exec(p, root, json); const x = await exec(p, root, json, prebuilt.size ? { [PREBUILD_DONE_ENV]: [...prebuilt].join('\n') } : {});
r.exitCode = x.code; r.ms = Date.now() - t; r.exitCode = x.code; r.ms = Date.now() - t;
if (x.code === 0) { if (x.code === 0) {
r.status = 'ok'; r.status = 'ok';
const pre = p.argv ? prebuildRan(p.argv, c.config) : null;
if (pre) prebuilt.add(pre);
if (p.capture) { if (p.capture) {
if (/^[\w.:/@+-]{1,200}$/.test(x.last)) { vars[p.capture] = x.last; r.detail = `${p.capture}=${x.last}`; } if (/^[\w.:/@+-]{1,200}$/.test(x.last)) { vars[p.capture] = x.last; r.detail = `${p.capture}=${x.last}`; }
else warn(TAG, `${s.id}: capture ${p.capture}: the last output line is not a single value ("${x.last.slice(0, 60)}")`); else warn(TAG, `${s.id}: capture ${p.capture}: the last output line is not a single value ("${x.last.slice(0, 60)}")`);

View file

@ -534,6 +534,8 @@ export interface ShipConfig {
reportsBucket?: string; reportsBucket?: string;
/** `storebound play build`: Gradle project folder, module, flavor (found automatically when unset). */ /** `storebound play build`: Gradle project folder, module, flavor (found automatically when unset). */
build?: PlayBuildConfig; build?: PlayBuildConfig;
/** Shell command `play build` runs first in the project root; overrides build.prebuild for Play. */
prebuild?: string;
/** Play Console > App content > Target audience and content: the age groups you select. Any group under 13 puts /** Play Console > App content > Target audience and content: the age groups you select. Any group under 13 puts
* the app under the Families policy (`storebound scan` checks ad SDKs, the advertising ID and location for it). */ * the app under the Families policy (`storebound scan` checks ad SDKs, the advertising ID and location for it). */
targetAges?: PlayAgeGroup[]; targetAges?: PlayAgeGroup[];
@ -579,6 +581,8 @@ export interface ShipConfig {
signing?: AppleSigning; signing?: AppleSigning;
/** `storebound apple build`: workspace / project, scheme, configuration, output folder (found automatically when unset). */ /** `storebound apple build`: workspace / project, scheme, configuration, output folder (found automatically when unset). */
build?: AppleBuildConfig; build?: AppleBuildConfig;
/** Shell command `apple build` runs first in the project root; overrides build.prebuild for the App Store. */
prebuild?: string;
/** Promoted in-app purchases on the product page, in order (`storebound apple promoted`; at most 20). */ /** Promoted in-app purchases on the product page, in order (`storebound apple promoted`; at most 20). */
promoted?: PromotedConfig; promoted?: PromotedConfig;
/** Custom product pages (`storebound apple cpp`; at most 70). */ /** Custom product pages (`storebound apple cpp`; at most 70). */
@ -620,6 +624,14 @@ export interface ShipConfig {
/** Owner answers for console forms that have no API (`storebound console fill`); only what the rest of the config /** Owner answers for console forms that have no API (`storebound console fill`); only what the rest of the config
* cannot prove. Anything left out stays empty and highlighted in the page. */ * cannot prove. Anything left out stays empty and highlighted in the page. */
console?: ConsoleConfig; console?: ConsoleConfig;
/** Native builds (`apple build`, `play build`). */
build?: {
/** Shell command run in the project root before the archive / bundle, e.g. "npm run build && npx cap sync" for a
* Capacitor app (the native build ships the web assets cap sync copied last). Printed, output streamed, a nonzero
* exit stops the build; `--no-prebuild` skips it; inside one `flow run` it runs once, not per store.
* apple.prebuild / play.prebuild override it per store. */
prebuild?: string;
};
} }
// ---- growth extras ---- // ---- growth extras ----
@ -700,6 +712,11 @@ const SHA256_RE = /^([0-9A-F]{2}:){31}[0-9A-F]{2}$/;
/** Problems of the growth-extras sections (web, revenuecat, prices.ppp, apple.teamId / adhoc). Pure. */ /** Problems of the growth-extras sections (web, revenuecat, prices.ppp, apple.teamId / adhoc). Pure. */
export function validateExtras(cfg: Partial<ShipConfig>): string[] { export function validateExtras(cfg: Partial<ShipConfig>): string[] {
const e: string[] = []; const e: string[] = [];
const badCmd = (v: unknown) => v !== undefined && (typeof v !== 'string' || !v.trim());
if (cfg.build !== undefined && (typeof cfg.build !== 'object' || cfg.build === null)) e.push('build: { prebuild?: "<shell command>" }');
if (badCmd(cfg.build?.prebuild)) e.push('build.prebuild: a shell command run in the project root before apple build / play build, e.g. "npm run build && npx cap sync"');
if (badCmd(cfg.apple?.prebuild)) e.push('apple.prebuild: a shell command (overrides build.prebuild for apple build)');
if (badCmd(cfg.play?.prebuild)) e.push('play.prebuild: a shell command (overrides build.prebuild for play build)');
if (cfg.apple?.teamId !== undefined &&!/^[A-Z0-9]{10}$/.test(cfg.apple.teamId)) e.push('apple.teamId: the 10-character Team ID (Membership details)'); if (cfg.apple?.teamId !== undefined &&!/^[A-Z0-9]{10}$/.test(cfg.apple.teamId)) e.push('apple.teamId: the 10-character Team ID (Membership details)');
if (cfg.play?.developerId !== undefined && !/^\d{1,25}$/.test(cfg.play.developerId)) e.push('play.developerId: the digits of play.google.com/console/u/0/developers/<id>'); if (cfg.play?.developerId !== undefined && !/^\d{1,25}$/.test(cfg.play.developerId)) e.push('play.developerId: the digits of play.google.com/console/u/0/developers/<id>');
if (cfg.play?.appId !== undefined && !/^\d{1,25}$/.test(cfg.play.appId)) e.push('play.appId: the digits of play.google.com/console/u/0/developers/<developerId>/app/<appId>/app-dashboard'); if (cfg.play?.appId !== undefined && !/^\d{1,25}$/.test(cfg.play.appId)) e.push('play.appId: the digits of play.google.com/console/u/0/developers/<developerId>/app/<appId>/app-dashboard');

235
cli/lib/web-assets.ts Normal file
View file

@ -0,0 +1,235 @@
// Web assets of a Capacitor app inside the native projects, and the prebuild hook that refreshes them.
// A Capacitor app's native build ships whatever `npx cap sync` last copied into ios/App/App/public and
// android/app/src/main/assets/public. Without a fresh web build + cap sync, `apple build` / `play build` archive an old
// game (pitfall 23). So:
// - build.prebuild (or apple.prebuild / play.prebuild) is a shell command the build commands run first, in the
// project root; inside one `flow run` the same command runs once (STOREBOUND_PREBUILD_DONE carries what ran).
// - after it, the copied public/index.html must be newer than the source: the last git commit touching anything
// outside ios/ android/ node_modules/ store/ .storebound/, and <webDir>/index.html. Else the build stops
// (--allow-stale-web overrides). `storebound check` warns about the same when no prebuild is set.
// Where the Capacitor project is (capacitorProject): the root's capacitor.config.*, else one found one or two levels
// down (monorepo: package.json "workspaces", apps/*, packages/*, or a direct child folder; node_modules skipped).
// Exactly one is used (and named); several are an error that asks for play.build.dir / apple.build.*. A folder from the
// config (play.build.dir, apple.build.workspace / project) always wins: its nearest capacitor.config.* above it.
// The comparison (webStaleness) and the capacitor config reading (webDirOf) are pure.
import { createHash } from 'node:crypto';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative, sep } from 'node:path';
import type { ShipConfig } from './config';
import { log, warn } from './log';
import { pump } from './runner';
export type Store = 'apple' | 'play';
/** Where `npx cap sync` copies the web build per store (Capacitor's default native layout), from the Capacitor project. */
export const COPIED_INDEX: Record<Store, string> = {
apple: 'ios/App/App/public/index.html',
play: 'android/app/src/main/assets/public/index.html',
};
/** Folders whose commits never make the web assets stale (native projects, deps, store files, local state). */
export const NOT_SOURCE = ['ios', 'android', 'node_modules', 'store', '.storebound'];
/** Env of a flow run: the prebuild commands that already ran in it, one per line. */
export const PREBUILD_DONE_ENV = 'STOREBOUND_PREBUILD_DONE';
const CAP_CONFIGS = ['capacitor.config.ts', 'capacitor.config.js', 'capacitor.config.json'];
/** webDir of a capacitor config's text (ts, js or json), default "www". A simple read: the first `webDir: '...'`. */
export function webDirOf(text: string): string {
const m = /["']?webDir["']?\s*:\s*(["'`])([^"'`\n]+)\1/.exec(text);
return m?.[2]?.trim() || 'www';
}
/** A Capacitor project: its folder relative to the storebound root ('' = the root), config file and webDir. */
export interface CapProject { dir: string; config: string; webDir: string }
const capConfigIn = (abs: string): string | undefined => CAP_CONFIGS.find(f => existsSync(join(abs, f)));
function capAt(root: string, dir: string): CapProject | null {
const f = capConfigIn(join(root, dir));
if (!f) return null;
let text = '';
try { text = readFileSync(join(root, dir, f), 'utf8'); } catch { /* unreadable: default webDir */ }
return { dir, config: f, webDir: webDirOf(text) };
}
const isDir = (p: string): boolean => { try { return statSync(p).isDirectory(); } catch { return false; } };
const subdirs = (abs: string): string[] => { try { return readdirSync(abs).filter(n => !n.startsWith('.') && n !== 'node_modules' && isDir(join(abs, n))); } catch { return []; } };
/** Folders (relative, '/'-separated) one or two levels down that hold a capacitor.config.*: package.json workspaces
* (plain paths and one-level "dir/*" globs), apps/*, packages/* and the root's direct child folders. Sorted. */
export function capacitorCandidates(root: string): string[] {
const globs = new Set(['apps/*', 'packages/*', '*']);
try {
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { workspaces?: string[] | { packages?: string[] } };
const ws = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces?.packages ?? [];
for (const w of ws) if (typeof w === 'string') globs.add(w.replace(/^\.\//, '').replace(/\/$/, ''));
} catch { /* no package.json */ }
const dirs = new Set<string>();
for (const g of globs) {
const parts = g.split('/');
if (parts.length > 2 || parts.some(p => p === '**' || p === 'node_modules' || p.startsWith('.'))) continue;
const expand = (base: string, rest: string[]): void => {
if (!rest.length) { dirs.add(base); return; }
const [head, ...tail] = rest;
const names = head === '*' ? subdirs(join(root, base)) : head!.includes('*') ? [] : [head!];
for (const n of names) if (isDir(join(root, base, n))) expand(base ? `${base}/${n}` : n, tail);
};
expand('', parts);
}
return [...dirs].filter(d => capConfigIn(join(root, d))).sort();
}
/**
* The Capacitor project of a storebound root, or null when there is none. `hint`: a folder from the config
* (play.build.dir, the folder of apple.build.workspace / project), whose nearest capacitor.config.* above it (up to the
* root) wins. Else the root's own config, else exactly one found one or two levels down; several throw.
*/
export function capacitorProject(root: string, hint?: string): CapProject | null {
if (hint) {
for (let d = join(root, hint); ; d = dirname(d)) {
const rel = relative(root, d);
if (rel.startsWith('..')) break;
const c = capAt(root, rel.split(sep).join('/'));
if (c) return c;
if (!rel) break;
}
return null;
}
const top = capAt(root, '');
if (top) return top;
const found = capacitorCandidates(root);
if (found.length > 1) throw new Error(`several Capacitor projects (${found.join(', ')}): set play.build.dir (e.g. "${found[0]}/android") and apple.build.workspace or apple.build.project (e.g. "${found[0]}/ios/App/App.xcworkspace")`);
return found.length ? capAt(root, found[0]!) : null;
}
/** A path inside the Capacitor project, relative to the storebound root. */
export const inCap = (cap: CapProject, rel: string): string => (cap.dir ? `${cap.dir}/${rel}` : rel);
export interface Staleness {
/** True when the copied index.html is missing or older than the source. */
stale: boolean;
missing: boolean;
/** Newest source time (ms) or null when nothing is known (no git, no webDir/index.html). */
source: number | null;
}
/** Copied web assets vs the source, all times in ms (null = unknown / missing). Pure. */
export function webStaleness(t: { copied: number | null; commit: number | null; webIndex: number | null }): Staleness {
const known = [t.commit, t.webIndex].filter((x): x is number => typeof x === 'number' && Number.isFinite(x));
const source = known.length ? Math.max(...known) : null;
if (t.copied === null) return { stale: true, missing: true, source };
return { stale: source !== null && t.copied < source, missing: false, source };
}
const fmt = (ms: number | null): string => (ms === null ? 'unknown' : new Date(ms).toISOString().replace('T', ' ').slice(0, 19) + 'Z');
/** The build-stopping message (also `check`'s warning). `path`: the copied index.html, relative to the root. */
export function staleMessage(path: string, s: Staleness, copied: number | null): string {
return `web assets are older than the source (copied ${s.missing ? 'never' : fmt(copied)}, source ${fmt(s.source)}): `
+ `run your web build + cap sync, or set build.prebuild. ${path}${s.missing ? ' is missing' : ''}; --allow-stale-web builds anyway`;
}
/** Time (ms) of the last git commit touching anything in root outside NOT_SOURCE (at the root and in the Capacitor
* project folder `capDir`; node_modules anywhere); null without git or commits. */
export function lastSourceCommit(root: string, capDir = ''): number | null {
const ex = new Set(NOT_SOURCE.map(d => `:(exclude)${d}/`));
if (capDir) for (const d of ['ios', 'android']) ex.add(`:(exclude)${capDir}/${d}/`);
ex.add(':(exclude,glob)**/node_modules/**');
try {
const r = Bun.spawnSync(['git', 'log', '-1', '--format=%ct', '--', '.', ...ex], { cwd: root, stdout: 'pipe', stderr: 'pipe' });
if (r.exitCode !== 0) return null;
const s = Number(r.stdout.toString().trim());
return s > 0 ? s * 1000 : null;
} catch { return null; }
}
const mtime = (p: string): number | null => { try { return statSync(p).mtimeMs; } catch { return null; } };
export interface WebBundle { path: string; sha256: string; mtime: string }
/** The copied index.html that goes into the native build: path (from the root), sha256 (first 12 hex) and mtime. */
export function webBundle(root: string, store: Store, cap: CapProject): WebBundle | null {
const path = inCap(cap, COPIED_INDEX[store]);
const m = mtime(join(root, path));
if (m === null) return null;
return { path, sha256: createHash('sha256').update(readFileSync(join(root, path))).digest('hex').slice(0, 12), mtime: new Date(m).toISOString() };
}
/** Staleness of one store's copied assets in a Capacitor project. */
export function webState(root: string, store: Store, cap: CapProject): { staleness: Staleness; copied: number | null; path: string } {
const path = inCap(cap, COPIED_INDEX[store]);
const copied = mtime(join(root, path));
const staleness = webStaleness({ copied, commit: lastSourceCommit(root, cap.dir), webIndex: mtime(join(root, inCap(cap, cap.webDir), 'index.html')) });
return { staleness, copied, path };
}
/** The prebuild command of a store: apple.prebuild / play.prebuild, else build.prebuild. */
export function prebuildFor(config: ShipConfig, store: Store): string | undefined {
const own = config[store]?.prebuild;
return (own ?? config.build?.prebuild)?.trim() || undefined;
}
/** Prebuild commands that already ran in this flow run (from the env). */
export function prebuildsDone(env: Record<string, string | undefined> = process.env): Set<string> {
return new Set((env[PREBUILD_DONE_ENV] ?? '').split('\n').map(s => s.trim()).filter(Boolean));
}
/** Runs a prebuild command in root with sh -c, streaming its output; throws on a nonzero exit. */
export async function runPrebuild(cmd: string, root: string, tag: string): Promise<void> {
log(tag, `prebuild: $ ${cmd}`);
const p = Bun.spawn(['sh', '-c', cmd], { cwd: root, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: process.env });
const tail: string[] = [];
const each = (line: string, kind: 'stdout' | 'stderr'): void => {
tail.push(line); if (tail.length > 20) tail.shift();
(kind === 'stderr' ? console.error : console.log)(` ${line}`);
};
await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]);
const code = await p.exited;
if (code !== 0) throw new Error(`prebuild failed (exit ${code}): ${cmd}\n${tail.slice(-10).join('\n')}\n(--no-prebuild skips it)`);
log(tag, 'prebuild: ok');
}
export interface WebPlan {
prebuild: string | null;
/** Why the prebuild does not run: --no-prebuild, already ran in this flow run. */
prebuildSkip?: string;
/** The Capacitor project folder ('.' = the root), or null for other projects. */
capacitor: string | null;
bundle: WebBundle | null;
stale?: boolean;
staleMessage?: string;
}
/**
* The web step of `apple build` / `play build`: run the prebuild (unless --no-prebuild, already done in this flow
* run, or --dry), then check the copied assets of the Capacitor project `cap`. Returns what went in, for the plan /
* output.
*/
export async function webStep(o: { config: ShipConfig; root: string; cap: CapProject | null; store: Store; tag: string; dry: boolean; noPrebuild: boolean; allowStale: boolean; quiet?: boolean }): Promise<WebPlan> {
const cmd = prebuildFor(o.config, o.store) ?? null;
const plan: WebPlan = { prebuild: cmd, capacitor: o.cap ? o.cap.dir || '.' : null, bundle: null };
const say = (msg: string) => { if (!o.quiet) log(o.tag, msg); };
if (cmd) {
if (o.noPrebuild) plan.prebuildSkip = '--no-prebuild';
else if (prebuildsDone().has(cmd)) plan.prebuildSkip = 'already ran in this flow run';
if (plan.prebuildSkip) say(`prebuild skipped (${plan.prebuildSkip}): ${cmd}`);
else if (o.dry) say(`would run prebuild (in the project root): ${cmd}`);
else await runPrebuild(cmd, o.root, o.tag);
}
if (!o.cap) return plan;
const st = webState(o.root, o.store, o.cap);
plan.bundle = webBundle(o.root, o.store, o.cap);
plan.stale = st.staleness.stale;
if (st.staleness.stale) {
const msg = staleMessage(st.path, st.staleness, st.copied);
plan.staleMessage = msg;
if (o.dry) {
if (!o.quiet) warn(o.tag, cmd && !plan.prebuildSkip ? `${msg} (the prebuild above would refresh them)` : msg);
} else if (o.allowStale) warn(o.tag, `--allow-stale-web: building anyway; ${msg}`);
else throw new Error(msg);
}
if (plan.bundle) say(`web bundle: ${plan.bundle.path} sha256 ${plan.bundle.sha256}, ${plan.bundle.mtime}`);
return plan;
}

View file

@ -1,6 +1,8 @@
// Build a signed release Android App Bundle. // Build a signed release Android App Bundle.
// storebound play build [--version-code N] [--version-name 1.2.0] [--flavor prod] [--dir android] [--module app] // storebound play build [--version-code N] [--version-name 1.2.0] [--flavor prod] [--dir android] [--module app]
// [--out build/android] [--dry] [--json] // [--out build/android] [--no-prebuild] [--allow-stale-web] [--dry] [--json]
// First the prebuild (build.prebuild / play.prebuild: e.g. the web build + npx cap sync; once per flow run), then a
// Capacitor project's copied web assets (android/app/src/main/assets/public) must be newer than the source.
// Finds the Gradle project (android/ of Capacitor, React Native and Flutter apps, the root of a native one, a Unity // Finds the Gradle project (android/ of Capacitor, React Native and Flutter apps, the root of a native one, a Unity
// export with launcher/ + unityLibrary/), takes the next free versionCode from Google Play (play next-version-code) // export with launcher/ + unityLibrary/), takes the next free versionCode from Google Play (play next-version-code)
// unless --version-code is given, runs `gradlew :app:bundleRelease` (Flutter: flutter build appbundle) and copies the // unless --version-code is given, runs `gradlew :app:bundleRelease` (Flutter: flutter build appbundle) and copies the
@ -17,6 +19,7 @@ import { done, log, printJson, warn } from '../lib/log';
import { pump } from '../lib/runner'; import { pump } from '../lib/runner';
import { aabInfo, findBundles, gradleArgs, pickGradle, signingEnv } from '../lib/gradle'; import { aabInfo, findBundles, gradleArgs, pickGradle, signingEnv } from '../lib/gradle';
import { nextVersionCode } from '../lib/release'; import { nextVersionCode } from '../lib/release';
import { capacitorProject, webStep } from '../lib/web-assets';
import { playClient } from './client'; import { playClient } from './client';
import { usedVersionCodes } from './next-version-code'; import { usedVersionCodes } from './next-version-code';
@ -28,7 +31,12 @@ export async function run(ctx: Ctx): Promise<void> {
const pkg = need(config.play?.packageName, 'play.packageName'); const pkg = need(config.play?.packageName, 'play.packageName');
const cfg = config.play?.build ?? {}; const cfg = config.play?.build ?? {};
const flavor = a.get('flavor') ?? cfg.flavor; const flavor = a.get('flavor') ?? cfg.flavor;
const t = pickGradle(root, { dir: a.get('dir') ?? cfg.dir, module: a.get('module') ?? cfg.module, flavor }); const dir = a.get('dir') ?? cfg.dir;
// Capacitor in a monorepo (apps/native/android): play.build.dir / --dir wins, else the one Capacitor project found
const cap = capacitorProject(root, dir);
const sub = !dir && cap?.dir ? cap.dir : '';
const t = pickGradle(sub ? join(root, sub) : root, { dir, module: a.get('module') ?? cfg.module, flavor });
if (sub) t.notes.unshift(`Capacitor project: ${sub} (${sub}/${cap!.config}; the only one found; set play.build.dir to use another)`);
const dry = a.has('dry'); const dry = a.has('dry');
const json = a.has('json'); const json = a.has('json');
const out = resolve(a.get('out') ?? 'build/android'); const out = resolve(a.get('out') ?? 'build/android');
@ -52,8 +60,10 @@ export async function run(ctx: Ctx): Promise<void> {
const plan = { framework: t.framework, dir: relative(root, t.dir) || '.', module: t.module, task: t.framework === 'flutter' ? 'flutter build appbundle' : t.task, const plan = { framework: t.framework, dir: relative(root, t.dir) || '.', module: t.module, task: t.framework === 'flutter' ? 'flutter build appbundle' : t.task,
versionCode: code || null, versionName: versionName ?? null, signing: signing ? `upload key from ${process.env.ANDROID_KEYSTORE ? 'env' : '.storebound/credentials.json'} (alias ${signing.keyAlias})` : 'the project\'s own signingConfig', versionCode: code || null, versionName: versionName ?? null, signing: signing ? `upload key from ${process.env.ANDROID_KEYSTORE ? 'env' : '.storebound/credentials.json'} (alias ${signing.keyAlias})` : 'the project\'s own signingConfig',
command: argv.join(' '), out: relative(root, out) || '.', notes: t.notes }; command: argv.join(' '), out: relative(root, out) || '.', notes: t.notes };
const webOpts = { config, root, cap, store: 'play' as const, tag: TAG, noPrebuild: a.has('no-prebuild'), allowStale: a.has('allow-stale-web') };
if (dry) { if (dry) {
if (json) { printJson(plan); return; } const web = await webStep({ ...webOpts, dry: true, quiet: json });
if (json) { printJson({ ...plan, web }); return; }
log(TAG, `${t.framework}: ${plan.dir} ${plan.task}, versionCode ${code || '?'}${versionName ? `, versionName ${versionName}` : ''}`); log(TAG, `${t.framework}: ${plan.dir} ${plan.task}, versionCode ${code || '?'}${versionName ? `, versionName ${versionName}` : ''}`);
log(TAG, `signing: ${plan.signing}`); log(TAG, `signing: ${plan.signing}`);
for (const n of t.notes) log(TAG, n); for (const n of t.notes) log(TAG, n);
@ -61,6 +71,7 @@ export async function run(ctx: Ctx): Promise<void> {
done(TAG, 'build planned (dry run)'); done(TAG, 'build planned (dry run)');
return; return;
} }
const web = await webStep({ ...webOpts, dry: false });
if (signing && !existsSync(signing.storeFile)) throw new Error('ANDROID_KEYSTORE: the keystore file does not exist'); if (signing && !existsSync(signing.storeFile)) throw new Error('ANDROID_KEYSTORE: the keystore file does not exist');
if (!signing) warn(TAG, 'no ANDROID_KEYSTORE: the project\'s own release signingConfig must sign, else Play refuses the bundle'); if (!signing) warn(TAG, 'no ANDROID_KEYSTORE: the project\'s own release signingConfig must sign, else Play refuses the bundle');
for (const n of t.notes) log(TAG, n); for (const n of t.notes) log(TAG, n);
@ -85,7 +96,7 @@ export async function run(ctx: Ctx): Promise<void> {
if (code && info.versionCode !== code) warn(TAG, `the AAB has versionCode ${info.versionCode}, not ${code}: the build file sets it itself; read -PversionCode there or pass that number to play upload knowingly`); if (code && info.versionCode !== code) warn(TAG, `the AAB has versionCode ${info.versionCode}, not ${code}: the build file sets it itself; read -PversionCode there or pass that number to play upload knowingly`);
const dest = join(out, `${info.packageName}-${info.versionCode}.aab`); const dest = join(out, `${info.packageName}-${info.versionCode}.aab`);
copyFileSync(aab, dest); copyFileSync(aab, dest);
const result = { aab: dest, packageName: info.packageName, versionCode: info.versionCode, versionName: info.versionName ?? null }; const result = { aab: dest, packageName: info.packageName, versionCode: info.versionCode, versionName: info.versionName ?? null, web: web.bundle };
if (json) printJson(result); 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`); done(TAG, `${relative(root, dest)}: ${info.packageName} ${info.versionName ?? ''} (${info.versionCode}). Next: storebound play upload ${relative(root, dest)} --track internal --dry`);
} }

View file

@ -9,6 +9,7 @@ fails without the fix.
| # | date | lesson | guard | | # | date | lesson | guard |
|---|---|---|---| |---|---|---|---|
| 23 | 2026-10 | Capacitor: native build ships old web assets. `flow run release --apply` went straight into `apple build` (xcodebuild archive) with `ios/App/App/public` and `android/app/src/main/assets/public` 3.5 hours older than the latest source commit: the native projects hold the copy `npx cap sync` made last, and nothing ran the web build + cap sync first, so the old game would have shipped. | New `build.prebuild` (per store `apple.prebuild` / `play.prebuild`): `apple build` / `play build` run it first (printed, streamed, nonzero exit stops; `--dry` only prints; `--no-prebuild`; once per `flow run` through `STOREBOUND_PREBUILD_DONE`). After it, a Capacitor project's copied `public/index.html` must be newer than the last commit outside ios/ android/ node_modules/ store/ .storebound/ and `<webDir>/index.html`, else the build stops ("web assets are older than the source ..."; `--allow-stale-web` overrides). `check` warns when stale without a prebuild; build output and dry plan show the bundle's sha256 + mtime. Monorepo (`apps/native` with its own capacitor.config.ts, android/, ios/; `play build` failed with "no Gradle project ... in android or ."): the one Capacitor project one or two levels down is found and named, several ask for `play.build.dir` / `apple.build.*` (`cli/lib/web-assets.ts`, `tests/web-assets.test.ts`). |
| 22 | 2026-10 | `play build` built a good AAB (Capacitor 8 / AGP), then failed its own check with "AAB manifest without package / versionCode". The aapt2 proto manifest was read with element field numbers one too high (name 4, attribute 5, child 6 instead of Resources.proto's 3, 4, 5). The test fixture came from the same wrong encoder, so the tests passed. | `aabInfo` / `encodeProtoManifest` in `cli/lib/gradle.ts` follow Resources.proto. `tests/build-sign.test.ts` builds the manifest bytes by tag, without the encoder, and the parser was checked against real AGP bundles. Lesson: a parser's fixture must not come from the same code's encoder. | | 22 | 2026-10 | `play build` built a good AAB (Capacitor 8 / AGP), then failed its own check with "AAB manifest without package / versionCode". The aapt2 proto manifest was read with element field numbers one too high (name 4, attribute 5, child 6 instead of Resources.proto's 3, 4, 5). The test fixture came from the same wrong encoder, so the tests passed. | `aabInfo` / `encodeProtoManifest` in `cli/lib/gradle.ts` follow Resources.proto. `tests/build-sign.test.ts` builds the manifest bytes by tag, without the encoder, and the parser was checked against real AGP bundles. Lesson: a parser's fixture must not come from the same code's encoder. |
| 21 | 2026-10 | Play Data safety: conditional required rows. `play data-safety` for a game with username accounts (`accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH']`) got 400 "Invalid safety labels declaration: Response missing for PSL_ACCOUNT_DELETION_URL": once account creation is not `PSL_ACM_NONE`, Play requires the delete-account URL and the data-only deletion question (`PSL_SUPPORT_DATA_DELETION_BY_USER`, and `PSL_DATA_DELETION_URL` when it is yes), and refuses `PSL_HAS_OUTSIDE_APP_ACCOUNTS`; storebound wrote none of them. | `dataSafetyRows` writes them from `accounts.deletionUrl` and `dataSafety.partialDeletion` / `partialDeletionUrl`; `DATA_SAFETY_RULES` / `typeRules` / `dataSafetyIssues` in `cli/lib/data-safety.ts` check every conditional row in `check`, `project.declarations` and `play data-safety` (dry and real, before the POST); `tests/pure.test.ts` (conditional required rows), `tests/idempotency.test.ts`, `tests/app-privacy.test.ts`. | | 21 | 2026-10 | Play Data safety: conditional required rows. `play data-safety` for a game with username accounts (`accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH']`) got 400 "Invalid safety labels declaration: Response missing for PSL_ACCOUNT_DELETION_URL": once account creation is not `PSL_ACM_NONE`, Play requires the delete-account URL and the data-only deletion question (`PSL_SUPPORT_DATA_DELETION_BY_USER`, and `PSL_DATA_DELETION_URL` when it is yes), and refuses `PSL_HAS_OUTSIDE_APP_ACCOUNTS`; storebound wrote none of them. | `dataSafetyRows` writes them from `accounts.deletionUrl` and `dataSafety.partialDeletion` / `partialDeletionUrl`; `DATA_SAFETY_RULES` / `typeRules` / `dataSafetyIssues` in `cli/lib/data-safety.ts` check every conditional row in `check`, `project.declarations` and `play data-safety` (dry and real, before the POST); `tests/pure.test.ts` (conditional required rows), `tests/idempotency.test.ts`, `tests/app-privacy.test.ts`. |
| 20 | 2026-10 | Hours of selector-fix cycles of the scripted console engine (`console fill`): the real console kept differing from the model (App Privacy "Set Up" as plain text, purpose checkboxes without an accessible name, two information steps in a type dialog, Play's material radios that ignore the native input). | Agent-first console mode: `console plan <form>` gives an AI agent the start URL, steps, answer sheet with the console's labels, never-press list and verified UI facts; the agent drives the page with its own browser tool and stops before the final button; `console verify <form>` checks the result (App Privacy summaries from the page text, Play Create app from input states, API for the App Store record and age rating). The scripted fill stays a fast path (and now handles the verified facts). MCP: `console_plan`, `console_verify` (read-only). `tests/console-plan.test.ts`, `tests/console-flow.test.ts`. | | 20 | 2026-10 | Hours of selector-fix cycles of the scripted console engine (`console fill`): the real console kept differing from the model (App Privacy "Set Up" as plain text, purpose checkboxes without an accessible name, two information steps in a type dialog, Play's material radios that ignore the native input). | Agent-first console mode: `console plan <form>` gives an AI agent the start URL, steps, answer sheet with the console's labels, never-press list and verified UI facts; the agent drives the page with its own browser tool and stops before the final button; `console verify <form>` checks the result (App Privacy summaries from the page text, Play Create app from input states, API for the App Store record and age rating). The scripted fill stays a fast path (and now handles the verified facts). MCP: `console_plan`, `console_verify` (read-only). `tests/console-plan.test.ts`, `tests/console-flow.test.ts`. |

View file

@ -45,7 +45,14 @@ when the owner said so for this run). The MCP server offers only `apple_signing_
ExportOptions.plist (`method` app-store-connect, `destination` export, team, style, profiles) and both xcodebuild ExportOptions.plist (`method` app-store-connect, `destination` export, team, style, profiles) and both xcodebuild
commands. Then run it without `--dry` (minutes; full logs in `build/ios/xcodebuild-*.log`). It ends with the IPA commands. Then run it without `--dry` (minutes; full logs in `build/ios/xcodebuild-*.log`). It ends with the IPA
path, version and build. **Note the build number**: later steps use exactly it. path, version and build. **Note the build number**: later steps use exactly it.
- Capacitor: `npx cap sync ios` after the web build first. React Native / CocoaPods: `pod install` first (the dry - Capacitor: the archive ships the web assets `npx cap sync` copied last. Set `build.prebuild` (e.g.
`"npm run build && npx cap sync"`; per store `apple.prebuild` / `play.prebuild`) and `apple build` / `play build`
run it first (once per `flow run`; `--no-prebuild` skips it). Then the copied `public/index.html` must be newer
than the source (last commit outside ios/ android/ node_modules/ store/ .storebound/, `<webDir>/index.html`),
else the build stops; `--allow-stale-web` only with the owner's OK. The output names the web bundle (sha256 +
mtime). In a monorepo (`apps/native/capacitor.config.ts` with its own android/ and ios/) the one Capacitor
project found one or two levels down is used and named; with several, set `play.build.dir` and
`apple.build.workspace` / `project`. React Native / CocoaPods: `pod install` first (the dry
run warns when Pods/ is missing). Flutter: storebound runs `flutter build ios --config-only` with the build run warns when Pods/ is missing). Flutter: storebound runs `flutter build ios --config-only` with the build
number. Unity: build the Xcode project from Unity first. number. Unity: build the Xcode project from Unity first.
- Capacitor needs a UTF-8 locale; storebound sets `LANG` / `LC_ALL` for xcodebuild. - Capacitor needs a UTF-8 locale; storebound sets `LANG` / `LC_ALL` for xcodebuild.

View file

@ -36,6 +36,9 @@ after: []
onError: [] onError: []
``` ```
`continueOnError: true` lets a step fail without stopping the flow. `continueOnError: true` lets a step fail without stopping the flow.
A web build before the native builds (Capacitor: `npm run build && npx cap sync`) belongs in `build.prebuild` in the
config rather than an `sh:` step: `apple build` and `play build` run it themselves, and within one `flow run` it runs
once (the second build step prints "prebuild skipped (already ran in this flow run)").
## Modes ## Modes
- `flow run release VERSION=1.2.0` (default): read steps (check, validate, scan, release plan, next-build, status ...) - `flow run release VERSION=1.2.0` (default): read steps (check, validate, scan, release plan, next-build, status ...)

View file

@ -1,6 +1,6 @@
--- ---
name: store-pitfalls name: store-pitfalls
description: Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each - store lists that lag right after a create, dry runs that print a count instead of a plan, App Store names taken on a storefront, keyword limits in bytes, Data safety answers without code evidence, missing font glyphs in screenshots, console sign-in and sessions, Capacitor monorepo plugins, duplicates from retries, re-runs and parallel runs, App Store search ranks that are only a trend, Play without a search API, Play's Create app form (no API, opener and submit share a name, Free is final), App Privacy purposes over-mapped (Play advertising is Third-Party Advertising only), an unknown console page (bounded wait and a debug dump), a signed-in console read as a sign-in page (Google account widget frames), console forms driven agent-first. Read it before any store task, and when a store command does something you did not expect. description: Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each - store lists that lag right after a create, dry runs that print a count instead of a plan, App Store names taken on a storefront, keyword limits in bytes, Data safety answers without code evidence, missing font glyphs in screenshots, console sign-in and sessions, Capacitor monorepo plugins, duplicates from retries, re-runs and parallel runs, App Store search ranks that are only a trend, Play without a search API, Play's Create app form (no API, opener and submit share a name, Free is final), App Privacy purposes over-mapped (Play advertising is Third-Party Advertising only), an unknown console page (bounded wait and a debug dump), a signed-in console read as a sign-in page (Google account widget frames), console forms driven agent-first, stale Capacitor web assets. Read it before any store task, and when a store command does something you did not expect.
--- ---
# Store pitfalls # Store pitfalls
@ -250,7 +250,6 @@ Each entry: **symptom** → cause → what to do → what guards it now. The ful
- **Sources:** https://support.google.com/googleplay/android-developer/answer/10787469 (Data safety form, CSV import), - **Sources:** https://support.google.com/googleplay/android-developer/answer/10787469 (Data safety form, CSV import),
https://support.google.com/googleplay/android-developer/answer/13327111 (account deletion: delete-account link; https://support.google.com/googleplay/android-developer/answer/13327111 (account deletion: delete-account link;
deleting other data is a separate question). deleting other data is a separate question).
## Adding a lesson ## Adding a lesson
Every bug found in real use gets an entry in `docs/pitfalls.md` and here, and, where possible, a check or a test Every bug found in real use gets an entry in `docs/pitfalls.md` and here, and, where possible, a check or a test
that fails without the fix (see CONTRIBUTING.md). that fails without the fix (see CONTRIBUTING.md).
@ -262,3 +261,25 @@ that fails without the fix (see CONTRIBUTING.md).
was wrong with the bundle. was wrong with the bundle.
- **Do:** update storebound. Don't rebuild or change the Gradle setup for this error. - **Do:** update storebound. Don't rebuild or change the Gradle setup for this error.
- **Guard:** `aabInfo` in `cli/lib/gradle.ts`; a byte-level test that doesn't use storebound's own encoder. - **Guard:** `aabInfo` in `cli/lib/gradle.ts`; a byte-level test that doesn't use storebound's own encoder.
## 23. Capacitor: native build ships old web assets
- **Symptom:** `storebound flow run release --apply` went straight into `apple build` (xcodebuild archive) while
`ios/App/App/public` and `android/app/src/main/assets/public` were 3.5 hours older than the latest source commit:
the build would have shipped the old game.
- **Cause:** a Capacitor app's native projects contain a copy of the web build that `npx cap sync` made last. Neither
xcodebuild nor Gradle rebuilds or re-copies it; nothing ran the web build + cap sync before the native build.
- **Do:** set `build.prebuild` (e.g. `"npm run build && npx cap sync"`; per store `apple.prebuild` /
`play.prebuild`). `apple build` / `play build` run it first in the project root (printed, output streamed, a
nonzero exit stops the build; `--dry` only prints it; `--no-prebuild` skips it); inside one `flow run` the shared
command runs once, not per store. Never pass `--allow-stale-web` to get past the check without the owner knowing
that the old web build ships.
- **Guard:** after the prebuild, a Capacitor project's copied `public/index.html` must be newer than the last git
commit outside ios/ android/ node_modules/ store/ .storebound/ and than `<webDir>/index.html`, else the build stops
with "web assets are older than the source (copied ..., source ...)" (`webStep` / `webStaleness` in
`cli/lib/web-assets.ts`). `storebound check` warns when the assets are stale and no prebuild is set. The build output
and the dry-run plan show the web bundle that goes in (sha256 of the copied index.html and its mtime).
`tests/web-assets.test.ts`.
- **Monorepo:** with the Capacitor app in `apps/native` (its own capacitor.config.ts, android/, ios/), `play build`
once failed with "no Gradle project (settings.gradle) in android or .". The builds, the staleness check and `check`
now find the one Capacitor project one or two levels down (workspaces, apps/*, packages/*) and name it; several are
listed and need `play.build.dir` / `apple.build.workspace|project` (`capacitorProject` in `cli/lib/web-assets.ts`).

221
tests/web-assets.test.ts Normal file
View file

@ -0,0 +1,221 @@
// Capacitor web assets in the native builds (pitfall 20): build.prebuild, the staleness guard, check's warning, the
// web bundle in the plan, and one prebuild per flow run.
import { afterAll, describe, expect, test } from 'bun:test';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { validateConfig } from '../cli/lib/config';
import { prebuildRan } from '../cli/flow';
import { capacitorCandidates, capacitorProject, COPIED_INDEX, lastSourceCommit, prebuildFor, prebuildsDone, staleMessage, webBundle, webDirOf, webStaleness } from '../cli/lib/web-assets';
import { makeAab } from './build-fixtures';
const ROOT = join(import.meta.dir, '..');
const CLI = join(ROOT, 'cli/index.ts');
const tmp = mkdtempSync(join(tmpdir(), 'sb-web-'));
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
const touch = (base: string, rel: string, text = ''): void => { mkdirSync(join(base, rel, '..'), { recursive: true }); writeFileSync(join(base, rel), text); };
const age = (base: string, rel: string, ms: number): void => { const t = new Date(ms); utimesSync(join(base, rel), t, t); };
const sb = (cwd: string, ...args: string[]) => Bun.spawnSync([process.execPath, CLI, ...args], { cwd, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
env: { ...process.env, ASC_KEY_ID: '', ASC_ISSUER_ID: '', ASC_P8: '', PLAY_SERVICE_ACCOUNT: '', ANDROID_KEYSTORE: '', STOREBOUND_PREBUILD_DONE: '', HOME: join(tmp, 'home') } });
const text = (r: { stdout: Buffer; stderr: Buffer }) => r.stdout.toString() + r.stderr.toString();
const BASE = { app: { name: 'Example', privacyPolicyUrl: 'https://example.com/p', supportUrl: 'https://example.com', website: 'https://example.com', contactEmail: 'hi@example.com' },
languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }] };
/** A Capacitor project (webDir dist) with both native projects and a fake gradlew that leaves a real AAB. */
function project(name: string, extra: Record<string, unknown> = {}, o: { fresh?: boolean; sub?: string } = {}): string {
const d = join(tmp, name);
mkdirSync(d, { recursive: true });
const cfg = { ...BASE, apple: { bundleId: 'com.example.game', signing: { teamId: 'TEAMID1234' } }, play: { packageName: 'com.example.game' }, ...extra };
touch(d, 'storebound.config.ts', `export default ${JSON.stringify(cfg)};\n`);
touch(d, '.gitignore', '.storebound/\n');
const c = o.sub ? join(d, o.sub) : d; // the Capacitor project (a monorepo package with sub)
touch(c, 'capacitor.config.json', JSON.stringify({ appId: 'com.example.game', appName: 'Example', webDir: 'dist' }));
touch(c, 'ios/App/App.xcodeproj/project.pbxproj');
touch(c, 'android/settings.gradle'); touch(c, 'android/app/build.gradle');
writeFileSync(join(c, 'fixture.aab'), makeAab('com.example.game', 5));
touch(c, 'android/gradlew', '#!/bin/sh\nmkdir -p app/build/outputs/bundle/release && cp ../fixture.aab app/build/outputs/bundle/release/app-release.aab\n');
chmodSync(join(c, 'android/gradlew'), 0o755);
const now = Date.now();
touch(c, 'dist/index.html', '<html>new game</html>');
age(c, 'dist/index.html', now - 60_000);
for (const s of ['apple', 'play'] as const) {
touch(c, COPIED_INDEX[s], '<html>old game</html>');
age(c, COPIED_INDEX[s], o.fresh ? now - 1_000 : now - 3.5 * 3_600_000);
}
return d;
}
describe('pure parts', () => {
test('webDir from ts, js and json configs; default www', () => {
expect(webDirOf('{"appId":"x","webDir":"dist"}')).toBe('dist');
expect(webDirOf("const config: CapacitorConfig = {\n appId: 'x',\n webDir: 'build/web',\n};")).toBe('build/web');
expect(webDirOf('module.exports = { webDir: `out` }')).toBe('out');
expect(webDirOf('export default { appId: "x" }')).toBe('www');
});
test('staleness: missing, older than a commit or the web build, fresh, unknown source', () => {
expect(webStaleness({ copied: null, commit: 10, webIndex: null })).toEqual({ stale: true, missing: true, source: 10 });
expect(webStaleness({ copied: 100, commit: 200, webIndex: 50 })).toEqual({ stale: true, missing: false, source: 200 });
expect(webStaleness({ copied: 100, commit: 50, webIndex: 150 })).toEqual({ stale: true, missing: false, source: 150 });
expect(webStaleness({ copied: 300, commit: 200, webIndex: 250 }).stale).toBe(false);
expect(webStaleness({ copied: 200, commit: 200, webIndex: null }).stale).toBe(false);
expect(webStaleness({ copied: 1, commit: null, webIndex: null })).toEqual({ stale: false, missing: false, source: null });
const msg = staleMessage(COPIED_INDEX.apple, webStaleness({ copied: 0, commit: 3_600_000, webIndex: null }), 0);
expect(msg).toStartWith('web assets are older than the source (copied 1970-01-01 00:00:00Z, source 1970-01-01 01:00:00Z): run your web build + cap sync, or set build.prebuild');
expect(msg).toContain('--allow-stale-web');
});
test('prebuild per store, flow marker, config validation', () => {
const c = { ...BASE, build: { prebuild: 'npm run build && npx cap sync' }, apple: { bundleId: 'com.example.game', prebuild: 'npm run ios' }, play: { packageName: 'com.example.game' } } as never;
expect(prebuildFor(c, 'apple')).toBe('npm run ios');
expect(prebuildFor(c, 'play')).toBe('npm run build && npx cap sync');
expect(prebuildRan(['play', 'build', '--version-code', '3'], c)).toBe('npm run build && npx cap sync');
expect(prebuildRan(['play', 'build', '--dry'], c)).toBeNull();
expect(prebuildRan(['play', 'build', '--no-prebuild'], c)).toBeNull();
expect(prebuildRan(['apple', 'upload'], c)).toBeNull();
expect([...prebuildsDone({ STOREBOUND_PREBUILD_DONE: 'a\nb\n' })]).toEqual(['a', 'b']);
expect(validateConfig({ ...BASE, build: { prebuild: '' } }).join()).toContain('build.prebuild');
expect(validateConfig({ ...BASE, play: { packageName: 'com.example.game', prebuild: 3 } }).join()).toContain('play.prebuild');
});
test('last source commit ignores ios/ android/ node_modules/ store/ .storebound/', () => {
const d = join(tmp, 'git');
mkdirSync(d);
const git = (date: string, ...a: string[]) => Bun.spawnSync(['git', '-c', 'user.name=t', '-c', 'user.email=t@example.com', '-c', 'commit.gpgsign=false', ...a], { cwd: d, env: { ...process.env, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date } });
git('2026-01-01T00:00:00Z', 'init', '-q');
expect(lastSourceCommit(d)).toBeNull();
touch(d, 'src/game.ts', 'x');
git('2026-01-01T00:00:00Z', 'add', '.'); git('2026-01-01T00:00:00Z', 'commit', '-qm', 'src');
touch(d, 'ios/App/x.swift', 'y'); touch(d, 'store/listing.md', 'z');
git('2026-02-01T00:00:00Z', 'add', '.'); git('2026-02-01T00:00:00Z', 'commit', '-qm', 'native');
expect(lastSourceCommit(d)).toBe(Date.parse('2026-01-01T00:00:00Z'));
});
});
describe('builds', () => {
test('stale copied assets stop play build (no Gradle run); --allow-stale-web builds; the dry plan shows the bundle', () => {
const d = project('stale');
const dry = sb(d, 'play', 'build', '--dry', '--json', '--version-code', '5');
expect(dry.exitCode).toBe(0);
const j = JSON.parse(dry.stdout.toString());
const b = webBundle(d, 'play', capacitorProject(d)!)!;
expect(j.web).toMatchObject({ prebuild: null, capacitor: '.', stale: true, bundle: { path: COPIED_INDEX.play, sha256: b.sha256 } });
expect(b.sha256).toMatch(/^[0-9a-f]{12}$/);
const adry = JSON.parse(sb(d, 'apple', 'build', '--dry', '--json', '--build-number', '1').stdout.toString());
expect(adry.web).toMatchObject({ stale: true, bundle: { path: COPIED_INDEX.apple } });
const r = sb(d, 'play', 'build', '--version-code', '5');
expect(r.exitCode).toBe(1);
expect(text(r)).toContain('web assets are older than the source (copied ');
expect(text(r)).toContain('run your web build + cap sync, or set build.prebuild');
expect(existsSync(join(d, 'android/app/build'))).toBe(false);
const ok = sb(d, 'play', 'build', '--version-code', '5', '--allow-stale-web');
expect(ok.exitCode).toBe(0);
expect(text(ok)).toContain(`web bundle: ${COPIED_INDEX.play} sha256 ${b.sha256}`);
});
test('missing copied index.html stops the build', () => {
const d = project('missing');
rmSync(join(d, COPIED_INDEX.play));
const r = sb(d, 'play', 'build', '--version-code', '5');
expect(r.exitCode).toBe(1);
expect(text(r)).toContain('copied never');
expect(text(r)).toContain('is missing');
});
test('prebuild runs first and refreshes the assets; --dry and --no-prebuild never run it; a failing prebuild stops', () => {
const pre = `echo ran >> prebuild.log && echo '<html>fresh</html>' > ${COPIED_INDEX.play} && echo '<html>fresh</html>' > ${COPIED_INDEX.apple}`;
const d = project('pre', { build: { prebuild: pre } });
const dry = sb(d, 'play', 'build', '--dry', '--version-code', '5');
expect(dry.exitCode).toBe(0);
expect(text(dry)).toContain(`would run prebuild (in the project root): ${pre}`);
expect(existsSync(join(d, 'prebuild.log'))).toBe(false);
expect(sb(d, 'play', 'build', '--version-code', '5', '--no-prebuild').exitCode).toBe(1); // skipped, so still stale
expect(existsSync(join(d, 'prebuild.log'))).toBe(false);
const r = sb(d, 'play', 'build', '--version-code', '5');
expect(text(r)).toContain(`prebuild: $ ${pre}`);
expect(r.exitCode).toBe(0);
expect(readFileSync(join(d, 'prebuild.log'), 'utf8')).toBe('ran\n');
expect(text(r)).toContain(`web bundle: ${COPIED_INDEX.play} sha256 ${webBundle(d, 'play', capacitorProject(d)!)!.sha256}`);
const bad = project('pre-bad', { play: { packageName: 'com.example.game', prebuild: 'echo broken >&2; exit 3' } });
const f = sb(bad, 'play', 'build', '--version-code', '5');
expect(f.exitCode).toBe(1);
expect(text(f)).toContain('prebuild failed (exit 3)');
expect(existsSync(join(bad, 'android/app/build'))).toBe(false);
});
test('a flow run runs the shared prebuild once, not once per build step', () => {
const pre = `echo ran >> prebuild.log && touch ${COPIED_INDEX.play} ${COPIED_INDEX.apple}`;
const d = project('flow', { build: { prebuild: pre } });
touch(d, 'store/workflows/ship.yml', 'steps:\n - id: one\n run: play build --version-code 5\n - id: two\n run: play build --version-code 5 --out build/android2\n');
const dry = sb(d, 'flow', 'run', 'ship');
expect(dry.exitCode).toBe(0);
expect(existsSync(join(d, 'prebuild.log'))).toBe(false);
const r = sb(d, 'flow', 'run', 'ship', '--apply');
expect(text(r)).toContain('prebuild skipped (already ran in this flow run)');
expect(r.exitCode).toBe(0);
expect(readFileSync(join(d, 'prebuild.log'), 'utf8')).toBe('ran\n');
expect(existsSync(join(d, 'build/android2/com.example.game-5.aab'))).toBe(true);
});
});
describe('monorepo: the Capacitor project in apps/native', () => {
test('found one level down, through workspaces, apps/* or packages/*; several ask for the config; the config wins', () => {
const one = project('mono-one', {}, { sub: 'apps/native' });
expect(capacitorCandidates(one)).toEqual(['apps/native']);
expect(capacitorProject(one)).toEqual({ dir: 'apps/native', config: 'capacitor.config.json', webDir: 'dist' });
const ws = join(tmp, 'mono-ws');
touch(ws, 'package.json', JSON.stringify({ workspaces: ['clients/*'] }));
touch(ws, 'clients/mobile/capacitor.config.ts', "export default { webDir: 'build' };");
touch(ws, 'node_modules/x/capacitor.config.ts', '');
expect(capacitorProject(ws)).toMatchObject({ dir: 'clients/mobile', webDir: 'build' });
const two = project('mono-two', {}, { sub: 'apps/native' });
touch(two, 'packages/kiosk/capacitor.config.json', '{}');
expect(() => capacitorProject(two)).toThrow('several Capacitor projects (apps/native, packages/kiosk): set play.build.dir');
expect(capacitorProject(two, 'packages/kiosk/android')).toMatchObject({ dir: 'packages/kiosk', webDir: 'www' });
expect(capacitorProject(two, 'apps/native/ios/App')).toMatchObject({ dir: 'apps/native' });
});
test('play build and apple build use apps/native/android and apps/native/ios, say so, and check its web assets', () => {
const d = project('mono-build', {}, { sub: 'apps/native' });
const dry = sb(d, 'play', 'build', '--dry', '--json', '--version-code', '5');
expect(dry.exitCode).toBe(0);
const j = JSON.parse(dry.stdout.toString());
expect(j).toMatchObject({ framework: 'capacitor', dir: 'apps/native/android', web: { capacitor: 'apps/native', stale: true, bundle: { path: `apps/native/${COPIED_INDEX.play}` } } });
expect(j.notes[0]).toContain('Capacitor project: apps/native');
const a = JSON.parse(sb(d, 'apple', 'build', '--dry', '--json', '--build-number', '1').stdout.toString());
expect(a).toMatchObject({ framework: 'capacitor', project: 'apps/native/ios/App/App.xcodeproj', scheme: 'App', web: { capacitor: 'apps/native', bundle: { path: `apps/native/${COPIED_INDEX.apple}` } } });
const stale = sb(d, 'play', 'build', '--version-code', '5');
expect(stale.exitCode).toBe(1);
expect(text(stale)).toContain(`apps/native/${COPIED_INDEX.play}; --allow-stale-web`);
const ok = sb(d, 'play', 'build', '--version-code', '5', '--allow-stale-web');
expect(ok.exitCode).toBe(0);
expect(text(ok)).toContain('Capacitor project: apps/native');
expect(existsSync(join(d, 'build/android/com.example.game-5.aab'))).toBe(true);
expect(checkJson(sb(d, 'check', '--json')).warnings.some(w => w.includes(`apps/native/${COPIED_INDEX.play}`))).toBe(true);
// several: the build asks for the folder; an explicit play.build.dir wins
touch(d, 'packages/kiosk/capacitor.config.json', '{}');
expect(text(sb(d, 'play', 'build', '--dry', '--version-code', '5'))).toContain('several Capacitor projects (apps/native, packages/kiosk)');
expect(sb(d, 'play', 'build', '--dry', '--version-code', '5', '--dir', 'apps/native/android').exitCode).toBe(0);
});
});
describe('check', () => {
test('warns about stale web assets when no build.prebuild is set', () => {
const d = project('check');
const r = sb(d, 'check', '--json');
const warnings: string[] = checkJson(r).warnings;
expect(warnings.some(w => w.startsWith('play: web assets are older than the source') && w.includes('Set build.prebuild'))).toBe(true);
const fresh = project('check-fresh', {}, { fresh: true });
expect(checkJson(sb(fresh, 'check', '--json')).warnings.join()).not.toContain('web assets');
const withPre = project('check-pre', { build: { prebuild: 'npx cap sync' } });
expect(checkJson(sb(withPre, 'check', '--json')).warnings.join()).not.toContain('web assets');
});
});
/** check --json prints its log lines first, then the result object. */
function checkJson(r: { stdout: Buffer }): { warnings: string[] } {
const out = r.stdout.toString();
return JSON.parse(out.slice(out.indexOf('\n{') + 1)) as { warnings: string[] };
}