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:
parent
5fbb9e63e3
commit
366362d62b
12 changed files with 116 additions and 19 deletions
|
|
@ -2,6 +2,14 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- Fix (`flow run`, every confirming command): a declined confirmation is no longer a success. `confirm()` prints
|
||||
`declined: <reason>` and exits 3 when the owner answers no, when there is no terminal and no `--yes`, or under
|
||||
`serve` without the owner's dialog; `flow run` stops at that step with "step N declined: <reason>" (status
|
||||
`declined`), never "ok". `flow run --apply --yes` is the owner's approval for the run: write steps get `--yes`
|
||||
(reads, waits and owner steps do not); `--yes` without `--apply` is refused. Commands that went on with a partial
|
||||
write after a declined part (`play products` prices, `testflight testers --invite`, `distribute --submit-review`)
|
||||
now exit 3 too. Pitfall 24.
|
||||
|
||||
- Fix (`apple build`, `play build`, Capacitor): a native build no longer ships old web assets silently. New
|
||||
`build.prebuild` (per store `apple.prebuild` / `play.prebuild`): a shell command run in the project root before the
|
||||
archive / bundle (e.g. `npm run build && npx cap sync`; printed, output streamed, nonzero exit stops the build,
|
||||
|
|
|
|||
|
|
@ -324,7 +324,7 @@ run in the background with a log (`storebound shots render > .storebound/render.
|
|||
| `whats-new --languages en,de,tr` / `check` / `apply` | local | one file per language (`store/whats-new/`) for your agent (or a `--translate` hook of yours) to translate, per-store limits (Play 500, App Store 4000), then into both listing files |
|
||||
| `listing translate --to de,ja [--from en] [--translate "<cmd>"]` | local | per-language listing sections in both listing files (App Store name, subtitle, promotional text, keywords, description; Play title, short, full) for your agent (or a hook) to translate, marked as drafts until reviewed; keywords cleaned up |
|
||||
| `listing check` / `listing reviewed --lang <ids>` | local | every language section against the store limits and keyword rules, untranslated and unreviewed drafts (exit 1 on a problem) / remove the draft marker after a human read it |
|
||||
| `flow list \| validate \| run <name> [NAME=value] [--dry \| --apply] [--from <step> \| --resume]` | local + whatever the steps call | repo-local workflows (`store/workflows/*.yml`): steps in order with progress; writes run as dry runs unless `--apply`, owner steps are listed as your click and never run (see [Repo-local workflows](#repo-local-workflows)) |
|
||||
| `flow list \| validate \| run <name> [NAME=value] [--dry \| --apply [--yes]] [--from <step> \| --resume]` | local + whatever the steps call | repo-local workflows (`store/workflows/*.yml`): steps in order with progress; writes run as dry runs unless `--apply`, owner steps are listed as your click and never run (see [Repo-local workflows](#repo-local-workflows)) |
|
||||
| `catalog pull [--write]` | API, read-only + local | IAP and subscription texts on the stores vs the config: a proposal diff (`.storebound/pull/catalog.json`); the config is never rewritten |
|
||||
| `ads scan --ios <path>` | local | AdAttributionKit / SKAdNetwork: ad network ids, postback copy endpoints, attribution windows and cooldown, conversion value updates; exit 1 on a blocker |
|
||||
| `privacy-manifest --ios <path>` | local | PrivacyInfo.xcprivacy, required-reason APIs, SDKs without manifests |
|
||||
|
|
@ -646,7 +646,9 @@ $ storebound flow run release VERSION=1.2.0 --dry
|
|||
|
||||
- Read steps (check, validate, scan, release plan, next-build, status) run; **write steps run with `--dry`** unless
|
||||
`--apply`, and even then each command asks its own confirmation; wait steps check once; `sh:` steps (your own build
|
||||
commands) run only with `--apply`. `--dry` prints the plan without running anything.
|
||||
commands) run only with `--apply`. `--dry` prints the plan without running anything. `--apply --yes` is your
|
||||
approval of every write step of the run (no terminal needed); without it, a step whose confirmation cannot be
|
||||
answered stops the flow as "declined", never "ok".
|
||||
- Owner steps (submit, release, phased release, rollout, review replies, price imports, tester invitations, Beta App
|
||||
Review) are never executed, also when written as `run:`; the flow ends with the list of your clicks.
|
||||
- `capture:` turns a step's last output line into a variable (`${BUILD}`), `if:` skips a step (`apple`, `play`,
|
||||
|
|
|
|||
|
|
@ -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]",
|
||||
|
|
|
|||
26
cli/flow.ts
26
cli/flow.ts
|
|
@ -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'})`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ fails without the fix.
|
|||
|
||||
| # | date | lesson | guard |
|
||||
|---|---|---|---|
|
||||
| 24 | 2026-10 | `flow run release --apply --yes` ran `apple upload --no-wait`, which printed "not a terminal: re-run with --yes to confirm" and declined, yet the flow logged "4/13 upload: ok" and went on to `apple testflight wait` for a build that was never uploaded: a declined confirmation exited 0, and the flow's `--yes` never reached the steps. | `confirm()` (`cli/lib/ctx.ts`): every decline (answered no, not a terminal without `--yes`, under serve) prints `declined: <reason>` and exits 3 (`DECLINED_EXIT`). `flow run` reports "step N declined: <reason>" (status `declined`) and stops; `--apply --yes` adds `--yes` to write steps only (`planStep`, `declinedReason`; `tests/flow.test.ts`). |
|
||||
| 23 | 2026-10 | Capacitor: native build ships old web assets. `flow run release --apply` went straight into `apple build` (xcodebuild archive) with `ios/App/App/public` and `android/app/src/main/assets/public` 3.5 hours older than the latest source commit: the native projects hold the copy `npx cap sync` made last, and nothing ran the web build + cap sync first, so the old game would have shipped. | New `build.prebuild` (per store `apple.prebuild` / `play.prebuild`): `apple build` / `play build` run it first (printed, streamed, nonzero exit stops; `--dry` only prints; `--no-prebuild`; once per `flow run` through `STOREBOUND_PREBUILD_DONE`). After it, a Capacitor project's copied `public/index.html` must be newer than the last commit outside ios/ android/ node_modules/ store/ .storebound/ and `<webDir>/index.html`, else the build stops ("web assets are older than the source ..."; `--allow-stale-web` overrides). `check` warns when stale without a prebuild; build output and dry plan show the bundle's sha256 + mtime. Monorepo (`apps/native` with its own capacitor.config.ts, android/, ios/; `play build` failed with "no Gradle project ... in android or ."): the one Capacitor project one or two levels down is found and named, several ask for `play.build.dir` / `apple.build.*` (`cli/lib/web-assets.ts`, `tests/web-assets.test.ts`). |
|
||||
| 22 | 2026-10 | `play build` built a good AAB (Capacitor 8 / AGP), then failed its own check with "AAB manifest without package / versionCode". The aapt2 proto manifest was read with element field numbers one too high (name 4, attribute 5, child 6 instead of Resources.proto's 3, 4, 5). The test fixture came from the same wrong encoder, so the tests passed. | `aabInfo` / `encodeProtoManifest` in `cli/lib/gradle.ts` follow Resources.proto. `tests/build-sign.test.ts` builds the manifest bytes by tag, without the encoder, and the parser was checked against real AGP bundles. Lesson: a parser's fixture must not come from the same code's encoder. |
|
||||
| 21 | 2026-10 | Play Data safety: conditional required rows. `play data-safety` for a game with username accounts (`accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH']`) got 400 "Invalid safety labels declaration: Response missing for PSL_ACCOUNT_DELETION_URL": once account creation is not `PSL_ACM_NONE`, Play requires the delete-account URL and the data-only deletion question (`PSL_SUPPORT_DATA_DELETION_BY_USER`, and `PSL_DATA_DELETION_URL` when it is yes), and refuses `PSL_HAS_OUTSIDE_APP_ACCOUNTS`; storebound wrote none of them. | `dataSafetyRows` writes them from `accounts.deletionUrl` and `dataSafety.partialDeletion` / `partialDeletionUrl`; `DATA_SAFETY_RULES` / `typeRules` / `dataSafetyIssues` in `cli/lib/data-safety.ts` check every conditional row in `check`, `project.declarations` and `play data-safety` (dry and real, before the POST); `tests/pure.test.ts` (conditional required rows), `tests/idempotency.test.ts`, `tests/app-privacy.test.ts`. |
|
||||
|
|
|
|||
|
|
@ -46,7 +46,11 @@ once (the second build step prints "prebuild skipped (already ran in this flow r
|
|||
changes. Show this output to the owner.
|
||||
- `--dry`: the plan only, nothing runs.
|
||||
- `--apply`: writes and shell steps run for real, **only after the owner agreed in chat**; each write command still
|
||||
asks its own confirmation in the terminal. Never add `--yes` (flow validate refuses it).
|
||||
asks its own confirmation in the terminal. Never add `--yes` to a step (flow validate refuses it).
|
||||
- `--apply --yes`: the owner's approval of every write step of this run (they get `--yes`; reads, waits and owner
|
||||
steps do not). Only when the owner said so in chat for this run, e.g. a background run with a log where no terminal
|
||||
can answer. Without `--yes` and without a terminal, a confirming command declines: the flow stops there with
|
||||
"step N declined: not a terminal and no --yes ...", never "ok" (status `declined` in `--json` and the state file).
|
||||
- Owner steps never run in any mode: submit, release, phased release, rollout, sending review replies, price
|
||||
imports, inviting testers, Beta App Review, starting experiments, offer codes (AGENTS.md rule 1). They print as
|
||||
"YOUR CLICK" with the exact text, also when written as `run:`.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: store-pitfalls
|
||||
description: Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each - store lists that lag right after a create, dry runs that print a count instead of a plan, App Store names taken on a storefront, keyword limits in bytes, Data safety answers without code evidence, missing font glyphs in screenshots, console sign-in and sessions, Capacitor monorepo plugins, duplicates from retries, re-runs and parallel runs, App Store search ranks that are only a trend, Play without a search API, Play's Create app form (no API, opener and submit share a name, Free is final), App Privacy purposes over-mapped (Play advertising is Third-Party Advertising only), an unknown console page (bounded wait and a debug dump), a signed-in console read as a sign-in page (Google account widget frames), console forms driven agent-first, stale Capacitor web assets. Read it before any store task, and when a store command does something you did not expect.
|
||||
description: Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each - store lists that lag right after a create, dry runs that print a count instead of a plan, App Store names taken on a storefront, keyword limits in bytes, Data safety answers without code evidence, missing font glyphs in screenshots, console sign-in and sessions, Capacitor monorepo plugins, duplicates from retries, re-runs and parallel runs, App Store search ranks that are only a trend, Play without a search API, Play's Create app form (no API, opener and submit share a name, Free is final), App Privacy purposes over-mapped (Play advertising is Third-Party Advertising only), an unknown console page, a signed-in console read as a sign-in page (Google account widget frames), console forms driven agent-first, stale Capacitor web assets, a declined flow step logged ok. Read it before any store task, and when a store command does something you did not expect.
|
||||
---
|
||||
|
||||
# Store pitfalls
|
||||
|
|
@ -283,3 +283,16 @@ that fails without the fix (see CONTRIBUTING.md).
|
|||
once failed with "no Gradle project (settings.gradle) in android or .". The builds, the staleness check and `check`
|
||||
now find the one Capacitor project one or two levels down (workspaces, apps/*, packages/*) and name it; several are
|
||||
listed and need `play.build.dir` / `apple.build.workspace|project` (`capacitorProject` in `cli/lib/web-assets.ts`).
|
||||
|
||||
## 24. A flow step that declined its confirmation was logged "ok"
|
||||
- **Symptom:** `flow run release --apply` ran `storebound apple upload --no-wait`, which printed "not a terminal:
|
||||
re-run with --yes to confirm" and uploaded nothing, yet the flow logged "4/13 upload: ok" and went on to
|
||||
`apple testflight wait` for a build that was never uploaded.
|
||||
- **Cause:** a declined confirmation (`confirm()` in `cli/lib/ctx.ts`) returned from the command with exit 0, and
|
||||
`flow run --yes` was not passed on to the steps, so a run without a terminal could never confirm.
|
||||
- **Do:** read a step's output, not only its status. For a run without a terminal the owner can approve the writes
|
||||
with `flow run <name> --apply --yes` (only on their word, per run); otherwise run the flow in a terminal.
|
||||
- **Guard:** every declined or unanswerable confirmation prints `declined: <reason>` and exits 3 (`DECLINED_EXIT`);
|
||||
`flow run` stops with "step N declined: <reason>" (status `declined`, never `ok`) and adds `--yes` to write steps
|
||||
only with `--apply --yes` (`planStep` in `cli/lib/flow.ts`, `declinedReason` in `cli/flow.ts`).
|
||||
`tests/flow.test.ts` (non-TTY flow with a confirming step and no `--yes` fails at that step with "declined").
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ beforeEach(() => {
|
|||
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
process.exitCode = 0; // a declined confirmation sets it (DECLINED_EXIT); the tests below check it
|
||||
globalThis.fetch = realFetch;
|
||||
for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
|
|
@ -236,12 +237,14 @@ describe('command (mocked fetch)', () => {
|
|||
expect(out).toContain('100.00 -> 150.00 USD (+50%)');
|
||||
expect(out).toContain('not a terminal');
|
||||
expect(calls.some(c => c.method === 'PUT')).toBe(false);
|
||||
expect(process.exitCode).toBe(3);
|
||||
});
|
||||
test('pause under storebound serve is refused even with --yes', async () => {
|
||||
process.env.STOREBOUND_SERVE = '1';
|
||||
const calls = mockFetch(api);
|
||||
await quiet(() => run(ctx(['pause', '--campaign', '444555666', '--yes'])));
|
||||
expect(calls.some(c => c.method === 'PUT')).toBe(false);
|
||||
expect(process.exitCode).toBe(3);
|
||||
});
|
||||
test('budget with --yes in a terminal-less run sends the documented body', async () => {
|
||||
const calls = mockFetch(api);
|
||||
|
|
|
|||
|
|
@ -83,6 +83,12 @@ describe('steps', () => {
|
|||
expect(planStep({ id: 'sh', sh: 'npm run build' }, 'steps', 'apply', vars, C).skip).toBeUndefined();
|
||||
expect(planStep({ id: 'p', run: 'play build', if: 'play' }, 'steps', 'apply', vars, C).skip).toBe('if play: false');
|
||||
expect(planStep({ id: 'n', run: 'whats-new apply', if: 'exists store/whats-new' }, 'steps', 'apply', vars, C).skip).toBeUndefined();
|
||||
// flow run --apply --yes: the owner's approval reaches write steps only (never reads, waits or owner steps)
|
||||
expect(planStep(up, 'steps', 'apply', vars, C, {}, true)).toMatchObject({ argv: ['apple', 'upload', '--no-wait', '--yes'], yesAdded: true });
|
||||
expect(planStep(up, 'steps', 'dry', vars, C, {}, true).argv).toEqual(['apple', 'upload', '--no-wait', '--dry']);
|
||||
expect(planStep({ id: 'v', run: 'validate' }, 'steps', 'apply', vars, C, {}, true).argv).toEqual(['validate']);
|
||||
expect(planStep({ id: 'w', run: 'apple testflight wait --build 1' }, 'steps', 'apply', vars, C, {}, true).argv).not.toContain('--yes');
|
||||
expect(planStep({ id: 'r', run: 'apple release' }, 'steps', 'apply', vars, C, {}, true)).toMatchObject({ kind: 'owner', skip: expect.stringContaining('your click') });
|
||||
});
|
||||
test('conditions, variables, params', () => {
|
||||
expect(evalIf('apple && !play', C)).toBe(true);
|
||||
|
|
@ -191,6 +197,27 @@ steps:
|
|||
expect(sb(app, 'flow', 'run', 'nope').stderr.toString()).toContain('no flow "nope"');
|
||||
});
|
||||
|
||||
test('a declined confirmation (not a terminal, no --yes) stops the flow at that step, never "ok"', () => {
|
||||
const d = join(tmp, 'declined');
|
||||
mkdirSync(join(d, 'store', 'workflows'), { recursive: true });
|
||||
writeFileSync(join(d, 'storebound.config.ts'), `export default { app: { name: 'Example', privacyPolicyUrl: 'https://example.com/p', supportUrl: 'https://example.com', website: 'https://example.com', contactEmail: 'hi@example.com' },
|
||||
languages: [{ id: 'en', play: 'en-US' }], play: { packageName: 'com.example.game' } };\n`);
|
||||
writeFileSync(join(d, 'app.aab'), 'not really a bundle');
|
||||
writeFileSync(join(d, 'store', 'workflows', 'share.yml'), 'steps:\n - id: share\n run: play share --aab app.aab\n - id: after\n sh: touch after.txt\n');
|
||||
const direct = sb(d, 'play', 'share', '--aab', 'app.aab');
|
||||
expect(direct.exitCode).not.toBe(0);
|
||||
expect(direct.stderr.toString()).toContain('declined: not a terminal and no --yes');
|
||||
const r = sb(d, 'flow', 'run', 'share', '--apply');
|
||||
const out = r.stdout.toString() + r.stderr.toString();
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(out).toContain('step 1 declined: not a terminal and no --yes');
|
||||
expect(out).not.toContain('share: ok');
|
||||
expect(existsSync(join(d, 'after.txt'))).toBe(false);
|
||||
const state = JSON.parse(readFileSync(join(d, '.storebound/flows/share.json'), 'utf8'));
|
||||
expect(state.results.map((x: { id: string; status: string }) => `${x.id}:${x.status}`)).toEqual(['share:declined']);
|
||||
expect(sb(d, 'flow', 'run', 'share', '--yes').stderr.toString()).toContain('--yes only with --apply');
|
||||
});
|
||||
|
||||
test('MCP: flow_list and a dry flow_run that never applies', () => {
|
||||
const list = TOOLS.find(t => t.name === 'flow_list')!;
|
||||
const run = TOOLS.find(t => t.name === 'flow_run')!;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
// Loaded before every test file (bunfig.toml): no real waiting between the re-checks of cli/lib/upsert.ts or for
|
||||
// the store's list to settle after a create (cli/apple/iap.ts).
|
||||
import { afterEach } from 'bun:test';
|
||||
|
||||
process.env.STOREBOUND_UPSERT_WAIT_MS = '0';
|
||||
process.env.STOREBOUND_LIST_SETTLE_MS ??= '0';
|
||||
|
||||
// Commands run in-process set process.exitCode on purpose (a check with problems, a declined confirmation:
|
||||
// DECLINED_EXIT in cli/lib/ctx.ts). Tests assert it where it matters; it must not leak into bun test's own exit code.
|
||||
afterEach(() => { process.exitCode = 0; });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue