feat(workflows): add --dry-run flag to specify workflow run#3124
feat(workflows): add --dry-run flag to specify workflow run#3124fuleinist wants to merge 5 commits into
Conversation
Scoped to engine-only changes per maintainer design guidance: - StepContext.dry_run flag - RunState.dry_run field (persisted via save/load, restored on resume) - WorkflowEngine.execute(..., dry_run=...) + resume() restores it - CommandStep / PromptStep / GateStep short-circuit on context.dry_run with synthetic dry_run_message previews - specify workflow run --dry-run CLI flag + preview rendering - _coerce_options / _first_non_sentinel helpers for deterministic GateStep dry-run branch No new CLI commands introduced. --dry-run lives only on the step-based invocation path (specify workflow run), not on scaffolding commands. Closes: github#2661 Ref: github#2704 (closed per maintainer request, split into smaller PRs)
There was a problem hiding this comment.
Pull request overview
Adds a --dry-run mode to specify workflow run by propagating a dry-run flag through the workflow engine, persisting it in run state for resume, and teaching key step types (command, prompt, gate) to short-circuit and emit preview output instead of performing side effects.
Changes:
- Persist and propagate
dry_runacrossWorkflowEngine.execute(...),RunState.save/load, andresume(). - Add dry-run short-circuit behavior to
CommandStep,PromptStep, andGateStep. - Add
specify workflow run --dry-runCLI flag plus preview rendering, and introduce a dedicated dry-run test suite.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
tests/test_dry_run.py |
Adds coverage for step-level dry-run behavior, engine persistence/resume, and CLI output expectations (including --json behavior). |
src/specify_cli/workflows/steps/prompt/__init__.py |
Implements dry-run short-circuit for prompt steps and standardizes output fields (dry_run, executed, dispatched, etc.). |
src/specify_cli/workflows/steps/gate/__init__.py |
Implements dry-run non-interactive gate resolution with deterministic choice selection and option coercion helpers. |
src/specify_cli/workflows/steps/command/__init__.py |
Implements dry-run preview generation for command invocations without dispatching integration CLIs. |
src/specify_cli/workflows/engine.py |
Adds dry_run plumbing, persistence, and partial_state attachment for mid-run failures. |
src/specify_cli/workflows/base.py |
Adds dry_run to StepContext and documents intended preview semantics. |
src/specify_cli/__init__.py |
Adds the --dry-run flag, prints previews in non-JSON mode, and adds preview-print helper utilities. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
mnriem
left a comment
There was a problem hiding this comment.
Please address Copilot feedback
1. GateStep: preserve original output['message'] in dry-run (don't overwrite) 2. CommandStep: preserve original message, set dry_run_message separately 3. PromptStep: same pattern — dry_run_message separate from message 4. --dry-run help text: clarify not all step types short-circuit 5. base.py docstring: accurate about message vs dry_run_message contract 6. _print_dry_run_previews: escape Rich markup in preview text 7. JSON error path: emit valid JSON payload on engine exception 8. resume path: remove stale docstring claim about shared preview helper
|
Please address Copilot feedback |
- PromptStep: preserve original prompt in output['message'] - CommandStep: same — keep original command in message - GateStep validate(): accept tuple/list options - _first_non_sentinel: case-insensitive comparison - StepContext docstring: dry_run_message is stable preview surface - Banner: narrow wording to match actual scope - ValueError handler: print partial_state for dry-run - _escape_markup: use rich.markup.escape - workflow_resume: wire dry-run preview output
|
Addressed all 9 remaining Copilot review items in c19f6e1:
All 15 dry-run tests pass. |
| from rich.markup import escape as _rich_escape_markup | ||
|
|
||
|
|
||
| def _escape_markup(text: str) -> str: | ||
| """Escape Rich markup characters so user-controlled text can be | ||
| printed safely. Delegates to ``rich.markup.escape`` for canonical | ||
| handling of ``[``, ``]``, ``{``, ``}``, and other special chars. | ||
| """ | ||
| return _rich_escape_markup(text) |
| except ValueError as exc: | ||
| if getattr(state, "dry_run", False) and not json_output: | ||
| _print_dry_run_previews(getattr(exc, "partial_state", None)) | ||
| console.print(f"[red]Error:[/red] {exc}") | ||
| raise typer.Exit(1) | ||
| except Exception as exc: | ||
| if getattr(state, "dry_run", False) and not json_output: | ||
| _print_dry_run_previews(getattr(exc, "partial_state", None)) | ||
| console.print(f"[red]Resume failed:[/red] {exc}") | ||
| raise typer.Exit(1) |
| try: | ||
| preview_invocation = impl.build_command_invocation( | ||
| command, args_str | ||
| ) | ||
| except (ImportError, AttributeError, TypeError): |
- Move rich.markup.escape import to top-level (fixes E402) - Use exc.partial_state instead of unbound state var in except handlers - Copilot build_command_invocation: return full agent invocation for meaningful dry-run previews in default (non-skills) mode
fuleinist
left a comment
There was a problem hiding this comment.
Addressed all 3 remaining Copilot items from the latest review round:
- E402: moved \rom rich.markup import escape\ to top-level imports
- UnboundLocalError: except handlers now use \exc.partial_state\ instead of the unbound \state\ variable
- Copilot CommandStep preview: \�uild_command_invocation\ now returns \speckit. \ in default mode (was returning just \�rgs), so dry-run previews show the full agent invocation
@mnriem ready for re-review when you have a moment
| console.print("\n[bold yellow]DRY RUN previews:[/bold yellow]") | ||
| for step_id, result in step_results.items(): | ||
| if not isinstance(result, dict): | ||
| continue | ||
| output = result.get("output") or {} | ||
| if not output.get("dry_run"): | ||
| continue | ||
| step_id_display = _escape_markup(str(step_id)) | ||
| preview = output.get("dry_run_message") or output.get("message") or "" | ||
| preview_escaped = _escape_markup(preview) | ||
| console.print(f" [cyan][{step_id_display}][/cyan] {preview_escaped}") |
| options = config.get("options", ["approve", "reject"]) | ||
| if not isinstance(options, list) or not options: | ||
| if not isinstance(options, (list, tuple)) or not options: | ||
| errors.append( | ||
| f"Gate step {config.get('id', '?')!r}: 'options' must be a non-empty list." | ||
| f"Gate step {config.get('id', '?')!r}: 'options' must be a non-empty list or tuple." | ||
| ) |
| help=( | ||
| "Preview the workflow without dispatching any AI or shell " | ||
| "commands for built-in command, prompt, and gate steps. " | ||
| "Those steps emit a synthetic preview message; the run is " | ||
| "persisted so it can be inspected but not resumed to a " | ||
| "real run. Other step types (e.g. init, shell) may still " | ||
| "perform their normal work during dry-run." | ||
| ), |
| except ValueError as exc: | ||
| partial = getattr(exc, "partial_state", None) | ||
| if getattr(partial, "dry_run", False) and not json_output: | ||
| _print_dry_run_previews(partial) | ||
| console.print(f"[red]Error:[/red] {exc}") | ||
| raise typer.Exit(1) | ||
| except Exception as exc: | ||
| partial = getattr(exc, "partial_state", None) | ||
| if getattr(partial, "dry_run", False) and not json_output: | ||
| _print_dry_run_previews(partial) | ||
| console.print(f"[red]Resume failed:[/red] {exc}") | ||
| raise typer.Exit(1) |
|
Please address Copilot feedback |
|
All 4 remaining Copilot review items from the 2026-06-24 cycle are now addressed in c48c03a:
@mnriem — ready for re-review when you have a moment. |
Summary
Adds a --dry-run\ flag to \specify workflow run\ that previews step outputs without dispatching AI or shell commands. Per maintainer design guidance (\#2704), the flag lives only on the step-based invocation path — no new CLI commands introduced.
Changes
Engine layer
esume()\
Step implementations
CLI
Tests
Closes: #2661
Ref: #2704 (closed per maintainer request to split into smaller PRs)