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

@ -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/<Name>, 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 <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).
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<void> {
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<void> {
];
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<void> {
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<void> {
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`);
}

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
// --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<void> {
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);

View file

@ -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",

View file

@ -10,16 +10,19 @@
// 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
// 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<CondCtx> {
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<CondCtx & { config: ShipConfig | null }> {
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<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. */
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 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<void> {
const subParams = expand(flow, all, 'steps').params;
const vars: Record<string, string> = { ...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<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'); };
@ -174,10 +186,12 @@ async function runFlow(args: Args, root: string): Promise<void> {
}
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)}")`);

View file

@ -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<ShipConfig>): 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?: "<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.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?.developerAccount !== undefined && !(typeof cfg.play.developerAccount === 'string' && cfg.play.developerAccount.trim() && cfg.play.developerAccount.length <= 100))

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.
// 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<void> {
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<void> {
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<void> {
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<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`);
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`);
}