diff --git a/CHANGELOG.md b/CHANGELOG.md index a042590..ad2a730 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## 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 `/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 manifest without package / versionCode". The aapt2 proto manifest is read with Resources.proto's field numbers. Pitfall 22. diff --git a/README.md b/README.md index b61277e..2e342ba 100644 --- a/README.md +++ b/README.md @@ -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 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 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 [] --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 ` | 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) | @@ -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 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 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 [] [--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 status` | API | versions, builds, IAPs, submissions; `--wait-review` (exit 0 / 1 / 7); `--cancel-submission ` | diff --git a/cli/apple/build.ts b/cli/apple/build.ts index 7b96fe3..7764de8 100644 --- a/cli/apple/build.ts +++ b/cli/apple/build.ts @@ -1,6 +1,8 @@ // 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] -// [--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/, Flutter ios/Runner, // Unity Unity-iPhone), takes the next safe build number from App Store Connect (apple next-build) unless // --build-number is given, writes /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). import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; -import { join, relative } from 'node:path'; +import { dirname, join, relative } from 'node:path'; import { ascKeyFile } from '../lib/credentials'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; @@ -19,6 +21,7 @@ import { pump } from '../lib/runner'; import { parseMobileprovision, toXmlPlist } from '../lib/plist'; import { nextBuildNumber } from '../lib/release'; 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 { ascClient } from './asc'; import { findBundleId } from './signing'; @@ -69,7 +72,14 @@ export async function run(ctx: Ctx): Promise { const style = signing.style ?? 'automatic'; const dry = a.has('dry'); 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 out = resolve(a.get('out') ?? cfg.out ?? 'build/ios'); const version = a.get('version'); @@ -115,8 +125,10 @@ export async function run(ctx: Ctx): Promise { ]; 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 }; + 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 (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}`); for (const n of target.notes) log(TAG, n); for (const s of steps) log(TAG, `would run: ${s}`); @@ -125,6 +137,7 @@ export async function run(ctx: Ctx): Promise { 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)'); + const web = await webStep({ ...webOpts, dry: false }); for (const n of target.notes) log(TAG, n); mkdirSync(out, { recursive: true }); writeFileSync(join(out, 'ExportOptions.plist'), plist); @@ -142,7 +155,7 @@ export async function run(ctx: Ctx): Promise { 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.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); done(TAG, `${relative(root, result.ipa)}: ${info.bundleId} ${info.version} (${info.build}). Next: storebound apple upload ${relative(root, result.ipa)} --dry`); } diff --git a/cli/check.ts b/cli/check.ts index a1d92ac..32d72ba 100644 --- a/cli/check.ts +++ b/cli/check.ts @@ -5,7 +5,7 @@ // 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 import { existsSync, readFileSync } from 'node:fs'; -import { relative } from 'node:path'; +import { dirname, relative } from 'node:path'; import { appPrivacyMismatches } from './lib/app-privacy'; import { type AsoFinding, auditAso } from './lib/aso'; import { draftLocales } from './lib/aso-intel'; @@ -20,6 +20,7 @@ import { detectProject } from './lib/scan/project'; import { reviewShotProblem } from './apple/iap-review'; import { allSubscriptions } from './lib/subscriptions'; import { type CopyFile, validateCopy } from './shots/lib'; +import { type CapProject, capacitorProject, prebuildFor, staleMessage, webState } from './lib/web-assets'; function aso(ctx: Ctx): void { const { config, resolve } = ctx.loaded; @@ -119,7 +120,17 @@ export async function run(ctx: Ctx): Promise { if (e.length) throw new Error(e.join('; ')); return `${Object.keys(copy).length} languages x ${config.shots!.scenes.length} scenes`; }); - for (const i of profileIssues(config, { demoAccount: !!reviewDemoAccount(ctx.loaded.root) })) { + // 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) })) { if (i.level === 'error') problems.push(i.msg); else warn('check', i.msg); } const subs = allSubscriptions(config.subscriptions); diff --git a/cli/data/flags.json b/cli/data/flags.json index 1ea4eac..6b41967 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -56,10 +56,12 @@ ], "apple appinfo": [], "apple build": [ + "allow-stale-web", "build-number", "configuration", "keychain", "no-install", + "no-prebuild", "out", "project", "scheme", @@ -379,9 +381,11 @@ "threshold" ], "play build": [ + "allow-stale-web", "dir", "flavor", "module", + "no-prebuild", "out", "version-code", "version-name" @@ -655,6 +659,7 @@ "agent", "all", "all-pages", + "allow-stale-web", "alpha", "apk", "app", @@ -759,6 +764,7 @@ "no-install", "no-local", "no-mcp", + "no-prebuild", "no-save", "no-scan", "no-sheet", diff --git a/cli/flow.ts b/cli/flow.ts index ccf041d..f4fd234 100644 --- a/cli/flow.ts +++ b/cli/flow.ts @@ -10,16 +10,19 @@ // the last run did not finish (.storebound/flows/.json) // 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). +// 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 { dirname, join, relative } from 'node:path'; import type { Args } from './lib/args'; -import { CONFIG_NAMES, loadConfig } from './lib/config'; +import { CONFIG_NAMES, loadConfig, type ShipConfig } from './lib/config'; import { type CondCtx, expand, type Flow, flowsOfFile, kindOf, type Mode, parseParams, planStep, type PlannedStep, resumePoint, type RunState, startIndex, type StepResult, validateFlows, } from './lib/flow'; import { done, log, printJson, step, warn } from './lib/log'; import { childEnv, pump, selfCommand } from './lib/runner'; +import { PREBUILD_DONE_ENV, prebuildFor, prebuildsDone } from './lib/web-assets'; import { parseYaml } from './lib/yaml'; 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; } } -async function condCtx(root: string): Promise { - let apple = false, play = false; - try { const { config } = await loadConfig(undefined, root); apple = !!config.apple; play = !!config.play; } catch { /* no config: store conditions are false */ } - return { apple, play, exists: p => existsSync(join(root, p)) }; +async function condCtx(root: string): Promise { + let config: ShipConfig | null = null; + try { config = (await loadConfig(undefined, root)).config; } catch { /* no config: store conditions are false */ } + 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 = { 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. */ -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 = {}): Promise<{ code: number; last: string; out: string }> { 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[] = []; let last = ''; const each = (line: string, kind: 'stdout' | 'stderr') => { @@ -147,6 +158,7 @@ async function runFlow(args: Args, root: string): Promise { const subParams = expand(flow, all, 'steps').params; const vars: Record = { ...subParams, ...flow.params, ...(from ? last?.vars ?? {} : {}), ...params }; const c = await condCtx(root); + const prebuilt = prebuildsDone(); const state: RunState = { flow: name, mode, startedAt: new Date().toISOString(), vars, results: [], ok: true }; const env = process.env as Record; 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 { } if (!json) log(TAG, `${label}: ${describe(p)}${p.dryAdded ? ' (dry run)' : ''}`); 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; if (x.code === 0) { r.status = 'ok'; + const pre = p.argv ? prebuildRan(p.argv, c.config) : null; + if (pre) prebuilt.add(pre); if (p.capture) { 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)}")`); diff --git a/cli/lib/config.ts b/cli/lib/config.ts index e28438a..1cf8747 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -534,6 +534,8 @@ export interface ShipConfig { reportsBucket?: string; /** `storebound play build`: Gradle project folder, module, flavor (found automatically when unset). */ 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 * the app under the Families policy (`storebound scan` checks ad SDKs, the advertising ID and location for it). */ targetAges?: PlayAgeGroup[]; @@ -579,6 +581,8 @@ export interface ShipConfig { signing?: AppleSigning; /** `storebound apple build`: workspace / project, scheme, configuration, output folder (found automatically when unset). */ 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?: PromotedConfig; /** 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 * cannot prove. Anything left out stays empty and highlighted in the page. */ 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 ---- @@ -700,7 +712,12 @@ 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. */ export function validateExtras(cfg: Partial): string[] { const e: string[] = []; - if (cfg.apple?.teamId !== undefined && !/^[A-Z0-9]{10}$/.test(cfg.apple.teamId)) e.push('apple.teamId: the 10-character Team ID (Membership details)'); + 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?: "" }'); + 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.play?.developerId !== undefined && !/^\d{1,25}$/.test(cfg.play.developerId)) e.push('play.developerId: the digits of play.google.com/console/u/0/developers/'); 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//app//app-dashboard'); if (cfg.play?.developerAccount !== undefined && !(typeof cfg.play.developerAccount === 'string' && cfg.play.developerAccount.trim() && cfg.play.developerAccount.length <= 100)) diff --git a/cli/lib/web-assets.ts b/cli/lib/web-assets.ts new file mode 100644 index 0000000..1f27fd6 --- /dev/null +++ b/cli/lib/web-assets.ts @@ -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 /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 = { + 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(); + 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 = process.env): Set { + 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 { + 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 { + 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; +} diff --git a/cli/play/build.ts b/cli/play/build.ts index 5abe259..989d431 100644 --- a/cli/play/build.ts +++ b/cli/play/build.ts @@ -1,6 +1,8 @@ // Build a signed release Android App Bundle. // 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 // 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 @@ -17,6 +19,7 @@ import { done, log, printJson, warn } from '../lib/log'; import { pump } from '../lib/runner'; import { aabInfo, findBundles, gradleArgs, pickGradle, signingEnv } from '../lib/gradle'; import { nextVersionCode } from '../lib/release'; +import { capacitorProject, webStep } from '../lib/web-assets'; import { playClient } from './client'; import { usedVersionCodes } from './next-version-code'; @@ -28,7 +31,12 @@ export async function run(ctx: Ctx): Promise { const pkg = need(config.play?.packageName, 'play.packageName'); const cfg = config.play?.build ?? {}; 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 json = a.has('json'); const out = resolve(a.get('out') ?? 'build/android'); @@ -52,8 +60,10 @@ export async function run(ctx: Ctx): Promise { 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', 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 (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, `signing: ${plan.signing}`); for (const n of t.notes) log(TAG, n); @@ -61,6 +71,7 @@ export async function run(ctx: Ctx): Promise { done(TAG, 'build planned (dry run)'); 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) 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); @@ -85,7 +96,7 @@ export async function run(ctx: Ctx): Promise { 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`); 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); done(TAG, `${relative(root, dest)}: ${info.packageName} ${info.versionName ?? ''} (${info.versionCode}). Next: storebound play upload ${relative(root, dest)} --track internal --dry`); } diff --git a/docs/pitfalls.md b/docs/pitfalls.md index bd9fbee..ef35fa1 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -9,6 +9,7 @@ fails without the fix. | # | 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 `/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. | | 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
` 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 ` 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`. | diff --git a/skills/build-and-sign/SKILL.md b/skills/build-and-sign/SKILL.md index 3eb4058..a9adb13 100644 --- a/skills/build-and-sign/SKILL.md +++ b/skills/build-and-sign/SKILL.md @@ -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 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. - - 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/, `/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 number. Unity: build the Xcode project from Unity first. - Capacitor needs a UTF-8 locale; storebound sets `LANG` / `LC_ALL` for xcodebuild. diff --git a/skills/release-flows/SKILL.md b/skills/release-flows/SKILL.md index 94f33aa..9599330 100644 --- a/skills/release-flows/SKILL.md +++ b/skills/release-flows/SKILL.md @@ -36,6 +36,9 @@ after: [] onError: [] ``` `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 - `flow run release VERSION=1.2.0` (default): read steps (check, validate, scan, release plan, next-build, status ...) diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 32015b1..bde0880 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1,6 +1,6 @@ --- 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 @@ -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), https://support.google.com/googleplay/android-developer/answer/13327111 (account deletion: delete-account link; deleting other data is a separate question). - ## 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 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. - **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. + +## 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 `/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`). diff --git a/tests/web-assets.test.ts b/tests/web-assets.test.ts new file mode 100644 index 0000000..b2693ac --- /dev/null +++ b/tests/web-assets.test.ts @@ -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 = {}, 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', 'new game'); + age(c, 'dist/index.html', now - 60_000); + for (const s of ['apple', 'play'] as const) { + touch(c, COPIED_INDEX[s], 'old game'); + 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 'fresh' > ${COPIED_INDEX.play} && echo 'fresh' > ${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[] }; +}