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

@ -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)}")`);