// Repo-local workflows (`storebound flow`): named, ordered storebound steps kept in the project, e.g. a release flow // next-build -> build -> upload -> wait -> whats-new apply -> validate -> scan -> plan -> your Submit click. Pure parts: // reading and checking the definitions, classifying each step (read / write / owner / shell / wait), variables, `if` // conditions and the run plan. The runner is cli/flow.ts. // // Where flows live (all are read; a name defined twice is an error): // storebound.workflows.(yml|yaml|json|ts) several flows: { flows: { : Flow } } or { : Flow } // store/workflows/.(yml|yaml|json|ts) one flow per file, named after the file // A flow: // description: text // params: { VERSION: "1.0.0" } defaults; `flow run release VERSION=1.2.0` overrides // before: [steps] steps: [steps] after: [steps] onError: [steps] (hooks: before / after the steps, on a failure) // private: true only usable as a sub-flow (`flow: ` step), not listed or run directly // A step (exactly one of run / sh / owner / flow): // id: next-build unique in the flow; `--from ` resumes there // run: apple next-build a storebound command line (no "storebound" prefix); ${VAR} substituted // sh: npm run build:web a shell command of yours: only runs with --apply // owner: "App Store Connect: Submit for Review" never run: shown as "your click" // flow: beta the steps of another flow (ids prefixed "beta.") // capture: BUILD the last stdout line becomes ${BUILD} for later steps // if: apple | play | exists | ${VAR} | !${VAR} | ${A} == x | ${A} != x // continueOnError: true a failure is reported and the flow goes on // Safety: write steps run with --dry unless `flow run --apply`; `--yes` is refused in any step (the commands ask the // owner themselves; `flow run --apply --yes` is the owner's approval for the run and adds --yes to its write steps, which never changes the price of an existing product: --allow-price-changes is // refused in steps and never added, so such a step exits 3 and the flow stops as declined); owner steps (submit, release, rollout, sending replies, price changes, inviting testers, starting // experiments ...) are never executed, also when written as `run:`. export type StepKind = 'read' | 'write' | 'wait' | 'owner' | 'shell'; export interface FlowStep { id: string; run?: string; sh?: string; owner?: string; flow?: string; capture?: string; if?: string; continueOnError?: boolean; /** Free text shown next to the step. */ note?: string; } export interface Flow { name: string; file: string; description?: string; params: Record; before: FlowStep[]; steps: FlowStep[]; after: FlowStep[]; onError: FlowStep[]; private: boolean; } /** Commands that only read (stores or local files); they run for real in every mode except a --dry plan. */ const READ: string[][] = [ ['check'], ['validate'], ['scan'], ['inspect'], ['doctor'], ['apps'], ['release', 'status'], ['release', 'plan'], ['originality'], ['apple', 'next-build'], ['play', 'next-version-code'], ['apple', 'status'], ['play', 'status'], ['health'], ['catalog', 'diff'], ['triage'], ['shots', 'check'], ['whats-new', 'check'], ['listing', 'check'], ['apple', 'testflight', 'status'], ['apple', 'testflight', 'groups'], ['apple', 'signing', 'status'], ['apple', 'preorder', 'status'], ['apple', 'cpp', 'list'], ['apple', 'events', 'list'], ['ads', 'scan'], ['privacy-manifest'], ['apple', 'encryption'], ['ws', 'list'], ['ws', 'status'], ['ws', 'check'], ['ws', 'validate'], ['apple', 'adhoc', 'devices'], ['revenuecat', 'diff'], ['ads', 'apple', 'status'], ['ads', 'apple', 'campaigns'], ['ads', 'apple', 'report'], ['analytics', 'compare'], ]; /** Waits for a store (build processing): with --dry it checks once instead of waiting. */ const WAIT: string[][] = [['apple', 'testflight', 'wait']]; /** Never executed by a flow: the owner does these (AGENTS.md rule 1). */ const OWNER: string[][] = [ ['apple', 'release'], ['play', 'rollout'], ['reviews', 'send'], ['prices', 'import'], ['apple', 'iap-submit'], ['apple', 'resubmit'], ['apple', 'ppo', 'start'], ['apple', 'ppo', 'stop'], ['apple', 'cpp', 'submit'], ['apple', 'cpp', 'visible'], ['apple', 'events', 'submit'], ['apple', 'preorder', 'enable'], ['apple', 'preorder', 'end'], ['apple', 'testflight', 'testers'], ['apple', 'testflight', 'expire'], ['apple', 'offer-codes'], ['apple', 'win-back'], ['ads', 'apple', 'bid'], ['ads', 'apple', 'budget'], ['ads', 'apple', 'pause'], ['apple', 'adhoc', 'register'], ]; const OWNER_ARGS = ['--submit-review', '--manual-release', '--cancel-submission', '--production', '--phased', 'submit', 'publish', '--remove', '--invite']; /** Never allowed inside a flow step. */ const REFUSED_ARGS = ['--yes', '--apply', '--allow-price-changes']; /** Words of a command line, honouring "double" and 'single' quotes. */ export function splitArgs(line: string): string[] { const out: string[] = []; let cur = '', q: string | null = null, any = false; for (const c of line.trim()) { if (q) { if (c === q) q = null; else cur += c; continue; } if (c === '"' || c === '\'') { q = c; any = true; continue; } if (/\s/.test(c)) { if (cur || any) out.push(cur); cur = ''; any = false; continue; } cur += c; } if (q) throw new Error(`unterminated quote in: ${line}`); if (cur || any) out.push(cur); return out; } const words = (argv: string[]): string[] => { const w: string[] = []; for (const a of argv) { if (a.startsWith('-')) break; w.push(a); } return w; }; const starts = (w: string[], p: string[]) => p.length <= w.length && p.every((x, i) => w[i] === x); /** What a `run:` step is. */ export function classify(argv: string[]): StepKind { const w = words(argv); if (OWNER.some(p => starts(w, p)) || argv.some(a => OWNER_ARGS.includes(a))) return argv.includes('--dry') && !argv.some(a => OWNER_ARGS.includes(a)) ? 'read' : 'owner'; if (WAIT.some(p => starts(w, p))) return 'wait'; if (starts(w, ['apple', 'testflight', 'groups', 'sync'])) return 'write'; if (READ.some(p => starts(w, p))) return 'read'; // `api` reads run, writes stay dry inside the command itself; treat it as a write so it gets --dry return 'write'; } export const kindOf = (s: FlowStep, argv?: string[]): StepKind => s.owner !== undefined ? 'owner' : s.sh !== undefined ? 'shell' : classify(argv ?? splitArgs(s.run ?? '')); // ---- loading ---- const ID_RE = /^[a-z0-9][a-z0-9._-]{0,62}$/; const VAR_RE = /^[A-Z][A-Z0-9_]{0,40}$/; const STEP_KEYS = ['id', 'run', 'sh', 'owner', 'flow', 'capture', 'if', 'continueOnError', 'note']; const FLOW_KEYS = ['description', 'params', 'before', 'steps', 'after', 'onError', 'private']; function asSteps(v: unknown, where: string, e: string[]): FlowStep[] { if (v === undefined || v === null) return []; if (!Array.isArray(v)) { e.push(`${where}: must be a list of steps`); return []; } const out: FlowStep[] = []; v.forEach((x, i) => { const at = `${where}[${i}]`; if (typeof x !== 'object' || x === null || Array.isArray(x)) { e.push(`${at}: a step is a map (id, run | sh | owner | flow, ...)`); return; } const o = x as Record; for (const k of Object.keys(o)) if (!STEP_KEYS.includes(k)) e.push(`${at}: unknown key "${k}" (${STEP_KEYS.join(', ')})`); const kinds = ['run', 'sh', 'owner', 'flow'].filter(k => o[k] !== undefined); if (kinds.length !== 1) e.push(`${at}: exactly one of run, sh, owner, flow`); for (const k of ['id', 'run', 'sh', 'owner', 'flow', 'capture', 'if', 'note']) if (o[k] !== undefined && typeof o[k] !== 'string') e.push(`${at}.${k}: must be a string`); const id = typeof o.id === 'string' ? o.id : typeof o.flow === 'string' ? o.flow : ''; if (!ID_RE.test(id)) e.push(`${at}.id: "${id}" (lowercase letters, digits, . _ -)`); if (o.capture !== undefined && !VAR_RE.test(String(o.capture))) e.push(`${at}.capture: an UPPER_CASE name`); if (o.continueOnError !== undefined && typeof o.continueOnError !== 'boolean') e.push(`${at}.continueOnError: true or false`); out.push({ ...(o as unknown as FlowStep), id }); }); return out; } /** One flow definition (already parsed from YAML / JSON / TS) -> Flow, with every problem. */ export function toFlow(name: string, file: string, v: unknown): { flow: Flow | null; errors: string[] } { const e: string[] = []; const at = `${file}: ${name}`; if (!ID_RE.test(name)) e.push(`${at}: flow name must be lowercase letters, digits, . _ -`); if (typeof v !== 'object' || v === null || Array.isArray(v)) return { flow: null, errors: [...e, `${at}: a flow is a map (description, params, steps, ...)`] }; const o = v as Record; for (const k of Object.keys(o)) if (!FLOW_KEYS.includes(k)) e.push(`${at}: unknown key "${k}" (${FLOW_KEYS.join(', ')})`); const params: Record = {}; if (o.params !== undefined) { if (typeof o.params !== 'object' || o.params === null || Array.isArray(o.params)) e.push(`${at}.params: a map of NAME: default`); else for (const [k, val] of Object.entries(o.params)) { if (!VAR_RE.test(k)) e.push(`${at}.params.${k}: UPPER_CASE names`); params[k] = val === null || val === undefined ? '' : String(val); } } const flow: Flow = { name, file, ...(typeof o.description === 'string' ? { description: o.description } : {}), params, before: asSteps(o.before, `${at}.before`, e), steps: asSteps(o.steps, `${at}.steps`, e), after: asSteps(o.after, `${at}.after`, e), onError: asSteps(o.onError, `${at}.onError`, e), private: o.private === true, }; if (!flow.steps.length) e.push(`${at}: no steps`); return { flow, errors: e }; } /** Flows of one file: a single-flow file (store/workflows/.*) or a multi-flow file (storebound.workflows.*). */ export function flowsOfFile(file: string, data: unknown, single: string | null): { flows: Flow[]; errors: string[] } { if (single) { const r = toFlow(single, file, data); return { flows: r.flow ? [r.flow] : [], errors: r.errors }; } if (typeof data !== 'object' || data === null || Array.isArray(data)) return { flows: [], errors: [`${file}: expected a map of flows`] }; const d = data as Record; const map = (d.flows && typeof d.flows === 'object' && !Array.isArray(d.flows) ? d.flows : d) as Record; const flows: Flow[] = [], errors: string[] = []; for (const [n, v] of Object.entries(map)) { const r = toFlow(n, file, v); if (r.flow) flows.push(r.flow); errors.push(...r.errors); } return { flows, errors }; } /** Steps with `flow:` sub-flows expanded (ids prefixed), with cycle and unknown-flow errors. */ export function expand(flow: Flow, all: Map, part: 'before' | 'steps' | 'after' | 'onError' = 'steps', stack: string[] = []): { steps: FlowStep[]; errors: string[]; params: Record } { const errors: string[] = []; const out: FlowStep[] = []; const params: Record = {}; for (const s of flow[part]) { if (s.flow === undefined) { out.push(s); continue; } const sub = all.get(s.flow); if (!sub) { errors.push(`${flow.name}: step ${s.id}: no flow "${s.flow}"`); continue; } if ([...stack, flow.name].includes(sub.name)) { errors.push(`${flow.name}: cycle ${[...stack, flow.name, sub.name].join(' -> ')}`); continue; } const r = expand(sub, all, 'steps', [...stack, flow.name]); errors.push(...r.errors); for (const [k, v] of Object.entries({ ...sub.params, ...r.params })) if (!(k in params)) params[k] = v; for (const x of r.steps) out.push({ ...x, id: `${s.id}.${x.id}`, ...(s.if && x.if ? { if: `${s.if} && ${x.if}` } : s.if ? { if: s.if } : {}) }); } return { steps: out, errors, params }; } /** Everything wrong with a set of flows: duplicate names and ids, refused args, unknown commands and variables. */ export function validateFlows(flows: Flow[], o: { commands?: Set } = {}): string[] { const e: string[] = []; const all = new Map(); for (const f of flows) { if (all.has(f.name)) e.push(`flow "${f.name}" is defined in ${all.get(f.name)!.file} and ${f.file}`); else all.set(f.name, f); } for (const f of all.values()) { const vars = new Set(Object.keys(f.params)); for (const part of ['before', 'steps', 'after', 'onError'] as const) { const r = expand(f, all, part); e.push(...r.errors); for (const k of Object.keys(r.params)) vars.add(k); const ids = new Set(); for (const s of r.steps) { const at = `${f.name}: ${part === 'steps' ? '' : `${part} `}step ${s.id}`; 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`); if (s.capture) vars.add(s.capture); if (s.run !== undefined) { let argv: string[] = []; try { argv = splitArgs(s.run); } catch (x) { e.push(`${at}: ${(x as Error).message}`); continue; } if (!argv.length) { e.push(`${at}: empty run`); continue; } if (argv[0] === 'storebound') e.push(`${at}: write the command without "storebound" (run: ${argv.slice(1).join(' ')})`); if (argv[0] === 'flow') e.push(`${at}: use a "flow: " step instead of run: flow ...`); const bad = argv.find(a => REFUSED_ARGS.includes(a.split('=')[0]!)); if (bad) e.push(`${at}: ${bad} is not allowed in a flow (${bad === '--allow-price-changes' ? 'a price change of an existing product is the owner\'s own run' : 'writes are confirmed by the owner, --apply is for flow run'})`); if (o.commands) { const w = words(argv); const known = o.commands.has(`${w[0]} ${w[1]}`) || o.commands.has(`${w[0]} ${w[1]} ${w[2]}`) || o.commands.has(w[0] ?? ''); if (!known && argv[0] !== 'storebound' && argv[0] !== 'flow') e.push(`${at}: "${w.slice(0, 2).join(' ')}" is not a storebound command`); } } if (s.if !== undefined && !/\S/.test(s.if)) e.push(`${at}: empty if`); } } } return e; } // ---- variables, conditions ---- /** ${VAR} and ${env.NAME} substituted; unknown ones stay as written (the plan shows them). */ export function substitute(text: string, vars: Record, env: Record = {}): string { return text.replace(/\$\{([^}]*)\}/g, (m, k: string) => (k.startsWith('env.') ? env[k.slice(4)] ?? m : vars[k] ?? m)); } export interface CondCtx { apple: boolean; play: boolean; exists(path: string): boolean } /** `if:` after substitution: apple | play | exists | !x | a == b | a != b | x (non-empty, not false / 0); && joins. */ export function evalIf(expr: string, c: CondCtx): boolean { return expr.split('&&').every(part => { const s = part.trim(); if (s.startsWith('!')) return !evalIf(s.slice(1), c); if (s === 'apple') return c.apple; if (s === 'play') return c.play; const ex = /^exists\s+(.+)$/.exec(s); if (ex) return c.exists(ex[1]!.trim()); const cmp = /^(.*?)\s*(==|!=)\s*(.*)$/.exec(s); if (cmp) { const eq = cmp[1]!.trim() === cmp[3]!.trim(); return cmp[2] === '==' ? eq : !eq; } return s !== '' && s !== 'false' && s !== '0' && !/^\$\{[^}]*\}$/.test(s); }); } /** `KEY=value` positionals of `flow run KEY=value ...`. */ export function parseParams(args: string[]): Record { const out: Record = {}; for (const a of args) { const i = a.indexOf('='); if (i < 1 || !VAR_RE.test(a.slice(0, i))) throw new Error(`"${a}": parameters are NAME=value (UPPER_CASE name)`); out[a.slice(0, i)] = a.slice(i + 1); } return out; } // ---- the plan ---- export type Mode = 'plan' | 'dry' | 'apply'; export interface PlannedStep { id: string; part: 'before' | 'steps' | 'after' | 'onError'; kind: StepKind; /** What runs: storebound argv (run steps), the shell line (sh), the owner's click text (owner). */ argv?: string[]; shell?: string; owner?: string; /** Not run in this mode, and why. */ skip?: string; /** --dry was added (write steps without --apply, wait steps without --apply). */ dryAdded: boolean; /** --yes was added (write steps of `flow run --apply --yes`: the owner approved the writes for this run). */ yesAdded?: boolean; capture?: string; continueOnError: boolean; note?: string; } /** How one step runs in a mode, with the variables known so far. Pure. */ export function planStep(s: FlowStep, part: PlannedStep['part'], mode: Mode, vars: Record, c: CondCtx, env: Record = {}, yes = false): PlannedStep { const base = { id: s.id, part, continueOnError: !!s.continueOnError, ...(s.capture ? { capture: s.capture } : {}), ...(s.note ? { note: s.note } : {}) }; if (s.if !== undefined && !evalIf(substitute(s.if, vars, env), c)) { const what = s.owner !== undefined ? { owner: substitute(s.owner, vars, env) } : s.sh !== undefined ? { shell: substitute(s.sh, vars, env) } : { argv: splitArgs(substitute(s.run ?? '', vars, env)) }; return { ...base, ...what, kind: kindOf(s), dryAdded: false, skip: `if ${s.if}: false` }; } if (s.owner !== undefined) return { ...base, kind: 'owner', owner: substitute(s.owner, vars, env), dryAdded: false, skip: 'your click' }; if (s.sh !== undefined) { const shell = substitute(s.sh, vars, env); return { ...base, kind: 'shell', shell, dryAdded: false, ...(mode === 'apply' ? {} : { skip: 'shell step: runs with --apply' }) }; } const argv = splitArgs(substitute(s.run ?? '', vars, env)); const kind = classify(argv); if (kind === 'owner') return { ...base, kind, owner: `storebound ${argv.join(' ')}`, dryAdded: false, skip: 'your click (owner step: run it yourself after checking)' }; const addDry = (kind === 'write' || kind === 'wait') && mode !== 'apply' && !argv.includes('--dry'); const addYes = yes && kind === 'write' && mode === 'apply' && !argv.includes('--dry'); return { ...base, kind, argv: addDry ? [...argv, '--dry'] : addYes ? [...argv, '--yes'] : argv, dryAdded: addDry, ...(addYes ? { yesAdded: true } : {}), ...(mode === 'plan' ? { skip: 'plan only' } : {}) }; } /** Index of the step to start from: --from (exact, or the first id under a sub-flow prefix). */ export function startIndex(steps: FlowStep[], from: string | undefined): number { if (!from) return 0; const i = steps.findIndex(s => s.id === from || s.id.startsWith(`${from}.`)); if (i < 0) throw new Error(`--from ${from}: no such step (${steps.map(s => s.id).join(', ')})`); return i; } export interface StepResult { id: string; part: PlannedStep['part']; kind: StepKind; status: 'ok' | 'failed' | 'declined' | 'skipped' | 'owner' | 'planned'; exitCode?: number; ms?: number; detail?: string; command?: string } export interface RunState { flow: string; mode: Mode; startedAt: string; finishedAt?: string; vars: Record; results: StepResult[]; ok: boolean } /** First step of the last run that did not finish (failed, or never reached): where `--resume` starts. */ export function resumePoint(steps: FlowStep[], last: RunState | null): string | undefined { if (!last) return undefined; const done = new Set(last.results.filter(r => r.part === 'steps' && (r.status === 'ok' || r.status === 'skipped' || r.status === 'owner')).map(r => r.id)); return steps.find(s => !done.has(s.id))?.id; }