flow run: a declined confirmation stops the flow; --apply --yes confirms write steps

confirm() prints 'declined: <reason>' and exits 3 whenever the answer is no or nobody can answer (not a
terminal without --yes, serve without the owner's dialog), so no command reports success after declining.
flow run stops at such a step with 'step N declined: <reason>' (status declined), never ok. flow run
--apply --yes passes --yes to write steps only; --yes without --apply is refused. Pitfall 24.
This commit is contained in:
CRTkafa 2026-10-12 08:44:56 +08:00
parent 5fbb9e63e3
commit 366362d62b
12 changed files with 116 additions and 19 deletions

View file

@ -1028,7 +1028,7 @@
"flow": [
"storebound flow list [--json] the flows of this project and their steps",
"storebound flow validate [--json] definitions, sub-flow cycles, commands, variables; exit 1 on a problem",
"storebound flow run <name> [NAME=value ...] [--dry | --apply] [--from <step> | --resume] [--json]"
"storebound flow run <name> [NAME=value ...] [--dry | --apply [--yes]] [--from <step> | --resume] [--json]"
],
"health": [
"storebound health [--apple] [--play] [--days 7] [--json]",

View file

@ -1,11 +1,14 @@
// Repo-local workflows: named, ordered storebound steps kept in the project (definitions and rules: lib/flow.ts).
// storebound flow list [--json] the flows of this project and their steps
// storebound flow validate [--json] definitions, sub-flow cycles, commands, variables; exit 1 on a problem
// storebound flow run <name> [NAME=value ...] [--dry | --apply] [--from <step> | --resume] [--json]
// storebound flow run <name> [NAME=value ...] [--dry | --apply [--yes]] [--from <step> | --resume] [--json]
// default: read steps run, write steps run with --dry (nothing changes), wait steps check once, shell steps are
// skipped, owner steps (submit, release, rollout ...) are shown as "your click" and never run
// --dry: only the plan: nothing runs at all
// --apply: write steps and shell steps run for real; every write command still asks the owner itself
// --apply --yes: the owner's approval for this run: write steps get --yes (owner steps still never run)
// A step whose command declined its confirmation ("declined: ...", e.g. not a terminal without --yes) stops the flow:
// "step N declined: <reason>", never ok.
// --from <step>: start at that step (variables captured by the last run are reused); --resume: from the first step
// 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
@ -20,6 +23,7 @@ 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 { DECLINED_MARK } from './lib/ctx';
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';
@ -77,6 +81,12 @@ async function condCtx(root: string): Promise<CondCtx & { config: ShipConfig | n
return { apple: !!config?.apple, play: !!config?.play, exists: p => existsSync(join(root, p)), config };
}
/** The reason of a declined confirmation in a step's output (the "declined: ..." line of confirm()), else null. Pure. */
export function declinedReason(out: string): string | null {
const l = out.split('\n').reverse().find(x => x.startsWith(DECLINED_MARK));
return l ? l.slice(DECLINED_MARK.length).trim() : null;
}
/** 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 {
@ -140,6 +150,7 @@ async function runFlow(args: Args, root: string): Promise<void> {
const name = args._[1];
if (!name) throw new Error('usage: storebound flow run <name> [NAME=value ...] [--dry | --apply] [--from <step> | --resume]');
if (args.has('dry') && args.has('apply')) throw new Error('--dry or --apply, not both');
if (args.has('yes') && !args.has('apply')) throw new Error('--yes only with --apply (it confirms the write steps of this run)');
const { flows, errors } = await loadFlows(root);
const problems = [...errors, ...validateFlows(flows)];
if (problems.length) throw new Error(`flow definitions have problems (storebound flow validate):\n ${problems.join('\n ')}`);
@ -174,7 +185,7 @@ async function runFlow(args: Args, root: string): Promise<void> {
const runPart = async (part: PlannedStep['part'], steps: typeof parts.steps, offset = 0): Promise<boolean> => {
for (let i = offset; i < steps.length; i++) {
const s = steps[i]!;
const p = planStep(s, part, mode, vars, c, env);
const p = planStep(s, part, mode, vars, c, env, args.has('yes'));
const label = `${part === 'steps' ? '' : `${part} `}${step(i + 1, steps.length, s.id)}`;
const r: StepResult = { id: s.id, part, kind: p.kind, status: 'planned', command: describe(p) };
if (p.skip) {
@ -198,12 +209,13 @@ async function runFlow(args: Args, root: string): Promise<void> {
}
if (!json) log(TAG, `${label}: ok (${(r.ms / 1000).toFixed(1)}s)${r.detail ? ` ${r.detail}` : ''}`);
} else {
r.status = 'failed';
r.detail = x.out.split('\n').filter(Boolean).slice(-1)[0]?.slice(0, 300);
if (!json) warn(TAG, `${label}: failed (exit ${x.code})${p.continueOnError ? ', continueOnError: going on' : ''}`);
const why = declinedReason(x.out);
r.status = why ? 'declined' : 'failed';
r.detail = why ?? x.out.split('\n').filter(Boolean).slice(-1)[0]?.slice(0, 300);
if (!json) warn(TAG, why ? `step ${i + 1} declined: ${why}${s.id ? ` (${s.id})` : ''}` : `${label}: failed (exit ${x.code})${p.continueOnError ? ', continueOnError: going on' : ''}`);
}
state.vars = vars; state.results.push(r); save();
if (r.status === 'failed' && !p.continueOnError) return false;
if (r.status === 'declined' || (r.status === 'failed' && !p.continueOnError)) return false;
}
return true;
};
@ -217,7 +229,7 @@ async function runFlow(args: Args, root: string): Promise<void> {
if (json) printJson({ ...state, stateFile: mode === 'plan' ? null : relative(root, statePath(root, name)) });
else {
if (owner.length) { console.log('\nYour clicks (storebound never does these):'); for (const r of owner) console.log(` - ${r.command}`); }
const failed = state.results.find(r => r.status === 'failed' && r.part === 'steps');
const failed = state.results.find(r => (r.status === 'failed' || r.status === 'declined') && r.part === 'steps');
if (!ok && failed) log(TAG, `stopped at ${failed.id}. Fix it, then: storebound flow run ${name} --resume${mode === 'apply' ? ' --apply' : ''}`);
done(TAG, `${name}: ${ok ? 'done' : 'failed'} (${mode === 'plan' ? 'plan only' : mode === 'dry' ? 'dry run: nothing changed' : 'applied'})`);
}

View file

@ -27,15 +27,33 @@ export function byAppleLocale(langs: Language[]): Map<string, Language> {
* so the answer is no. The one exception: a desktop owner action (serve.ts OWNER_TOOLS: rollout changes, the phased
* release, sending one approved reply) whose confirm token the sidecar verified after the owner pressed the button of
* a native dialog showing the dry run; serve sets STOREBOUND_OWNER_CONFIRMED for that one child only.
* A "no" (answered, or nobody could answer) is never a success: it prints "declined: <reason>" on stderr and sets the
* exit code to DECLINED_EXIT, so a script or `flow run` stops there instead of going on as if the write happened.
*/
export async function confirm(ctx: Ctx, question: string): Promise<boolean> {
if (process.env.STOREBOUND_SERVE === '1') {
if (process.env.STOREBOUND_OWNER_CONFIRMED === '1') { console.error(`confirmed by the owner in the desktop dialog: ${question}`); return true; }
console.error(`confirm-required: ${question}\n terminal only: run it in a terminal`); return false;
console.error(`confirm-required: ${question}\n terminal only: run it in a terminal`);
return declined('confirm-required: terminal only');
}
if (ctx.args.has('yes')) return true;
if (!process.stdin.isTTY) { console.log(`${question}\n not a terminal: re-run with --yes to confirm`); return false; }
if (!process.stdin.isTTY) {
console.log(`${question}\n not a terminal: re-run with --yes to confirm`);
return declined('not a terminal and no --yes (re-run with --yes to confirm)');
}
process.stdout.write(`${question} [y/N] `);
for await (const line of console) return /^y(es)?$/i.test(line.trim());
for await (const line of console) return /^y(es)?$/i.test(line.trim()) || declined('the owner answered no');
return declined('no answer (stdin closed)');
}
/** Exit code of a command whose confirmation was declined or could not be asked. */
export const DECLINED_EXIT = 3;
/** Prefix of the stderr line a declined confirmation prints (`flow run` reports it as "step N declined: ..."). */
export const DECLINED_MARK = 'declined: ';
/** A declined confirmation: the reason on stderr, a nonzero exit code; returns false for confirm(). */
export function declined(reason: string): false {
console.error(`${DECLINED_MARK}${reason}`);
process.exitCode = DECLINED_EXIT;
return false;
}

View file

@ -21,7 +21,7 @@
// if: apple | play | exists <path> | ${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); owner steps (submit, release, rollout, sending replies, price changes, inviting testers, starting
// owner themselves; `flow run --apply --yes` is the owner's approval for the run and adds --yes to its write steps); 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';
@ -277,13 +277,15 @@ export interface PlannedStep {
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<string, string>, c: CondCtx, env: Record<string, string | undefined> = {}): PlannedStep {
export function planStep(s: FlowStep, part: PlannedStep['part'], mode: Mode, vars: Record<string, string>, c: CondCtx, env: Record<string, string | undefined> = {}, 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)) };
@ -298,7 +300,8 @@ export function planStep(s: FlowStep, part: PlannedStep['part'], mode: Mode, var
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');
return { ...base, kind, argv: addDry ? [...argv, '--dry'] : argv, dryAdded: addDry, ...(mode === 'plan' ? { skip: 'plan only' } : {}) };
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 <id> (exact, or the first id under a sub-flow prefix). */
@ -309,7 +312,7 @@ export function startIndex(steps: FlowStep[], from: string | undefined): number
return i;
}
export interface StepResult { id: string; part: PlannedStep['part']; kind: StepKind; status: 'ok' | 'failed' | 'skipped' | 'owner' | 'planned'; exitCode?: number; ms?: number; detail?: string; command?: string }
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<string, string>; results: StepResult[]; ok: boolean }
/** First step of the last run that did not finish (failed, or never reached): where `--resume` starts. */