validate: macOS versions need Mac screenshots, Play reads survive an ended edit; macOS build numbers across platforms; Mac build prebuild + apple.macAppCheck; signing wording off the Mac; dry flows plan uploads

This commit is contained in:
CRTkafa 2026-11-02 18:16:25 +07:00
parent b1f1b206d3
commit f5da7c9f7a
21 changed files with 519 additions and 57 deletions

View file

@ -205,7 +205,7 @@ export function validateFlows(flows: Flow[], o: { commands?: Set<string> } = {})
if (ids.has(s.id)) e.push(`${at}: id used twice`);
ids.add(s.id);
for (const t of [s.run, s.sh, s.owner, s.if].filter((x): x is string => typeof x === 'string'))
for (const m of t.matchAll(/\$\{([^}]*)\}/g)) if (!vars.has(m[1]!) && !/^env\.[A-Z0-9_]+$/.test(m[1]!)) e.push(`${at}: \${${m[1]}} is not a param or an earlier capture`);
for (const m of t.matchAll(/\$\{([^}]*)\}/g)) if (!vars.has(m[1]!) && !/^env\.[A-Z0-9_]+$/.test(m[1]!) && !CONFIG_VARS.includes(m[1]!)) e.push(`${at}: \${${m[1]}} is not a param or an earlier capture`);
if (s.capture) vars.add(s.capture);
if (s.run !== undefined) {
let argv: string[] = [];
@ -230,6 +230,14 @@ export function validateFlows(flows: Flow[], o: { commands?: Set<string> } = {})
// ---- variables, conditions ----
/** Variables the runner fills from the config (empty when the config has none), usable in `run` and `if`. */
export const CONFIG_VARS = ['config.testflight.internal'];
/** The required-param marker: `VERSION: "?"` in params means the run needs VERSION=... (no silent default). */
export const REQUIRED = '?';
/** Params still at the required marker (not given on the command line). Pure. */
export function missingParams(vars: Record<string, string>): string[] { return Object.keys(vars).filter(k => vars[k] === REQUIRED); }
/** ${VAR} and ${env.NAME} substituted; unknown ones stay as written (the plan shows them). */
export function substitute(text: string, vars: Record<string, string>, env: Record<string, string | undefined> = {}): string {
return text.replace(/\$\{([^}]*)\}/g, (m, k: string) => (k.startsWith('env.') ? env[k.slice(4)] ?? m : vars[k] ?? m));
@ -306,6 +314,39 @@ export function planStep(s: FlowStep, part: PlannedStep['part'], mode: Mode, var
return { ...base, kind, argv: addDry ? [...argv, '--dry'] : addYes ? [...argv, '--yes'] : argv, dryAdded: addDry, ...(addYes ? { yesAdded: true } : {}), ...(mode === 'plan' ? { skip: 'plan only' } : {}) };
}
const PRODUCERS = ['apple build', 'play build'];
const CONSUMERS = ['apple upload', 'play upload'];
const AFTER_UPLOAD = ['apple testflight wait', 'apple testflight distribute'];
/** Stores whose artifact (IPA / AAB / PKG) or uploaded build only exists after a real earlier step ("apple", "apple:mac", "play"). */
export interface DryPending { built: Set<string>; uploaded: Set<string> }
const storeKey = (argv: string[]): string => {
const i = argv.indexOf('--platform');
const mac = (i >= 0 && /^(macos|mac)$/i.test(argv[i + 1] ?? '')) || argv.includes('--tauri');
return `${argv[0]}${mac ? ':mac' : ''}`;
};
/** Records a step that ran as a dry run: a dry build leaves no IPA / AAB. Pure. */
export function noteDry(p: PlannedStep, pending: DryPending): void {
if (!p.argv || !p.dryAdded || p.skip) return;
if (PRODUCERS.includes(words(p.argv).slice(0, 2).join(' '))) pending.built.add(storeKey(p.argv));
}
/** Why a step is only planned in a dry run: its input (the IPA / AAB, or the uploaded build) would come from an earlier
* step that did not really run. null = run it (always null with --apply: a real upload with no IPA still fails). Pure. */
export function plannedForDryInput(p: PlannedStep, mode: Mode, pending: DryPending): string | null {
if (mode === 'apply' || !p.argv || p.skip) return null;
const w = words(p.argv);
const k2 = w.slice(0, 2).join(' ');
if (CONSUMERS.includes(k2) && !p.argv.some(a => /\.(ipa|aab|pkg)$/.test(a)) && pending.built.has(storeKey(p.argv))) {
pending.uploaded.add(storeKey(p.argv));
return `planned: would upload the ${k2 === 'play upload' ? 'AAB' : storeKey(p.argv).endsWith(':mac') ? 'PKG' : 'IPA'} the ${w[0]} build step writes (dry run: nothing built)`;
}
if (AFTER_UPLOAD.includes(w.slice(0, 3).join(' ')) && pending.uploaded.has('apple')) return 'planned: the build is not on App Store Connect in a dry run';
return null;
}
/** Index of the step to start from: --from <id> (exact, or the first id under a sub-flow prefix). */
export function startIndex(steps: FlowStep[], from: string | undefined): number {
if (!from) return 0;