Skip to content

feat(workflows): add --dry-run flag to specify workflow run#3124

Open
fuleinist wants to merge 2 commits into
github:mainfrom
fuleinist:feat/dry-run-workflow-run
Open

feat(workflows): add --dry-run flag to specify workflow run#3124
fuleinist wants to merge 2 commits into
github:mainfrom
fuleinist:feat/dry-run-workflow-run

Conversation

@fuleinist

Copy link
Copy Markdown

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

  • \StepContext.dry_run\ flag propagated from \WorkflowEngine.execute(dry_run=...)\
  • \RunState.dry_run\ persisted via save/load, restored on
    esume()\
  • Engine attaches \partial_state\ to exceptions so mid-run failures still surface previews from earlier steps

Step implementations

  • CommandStep: short-circuits on \context.dry_run, renders \�uild_command_invocation()\ preview when integration is configured, falls back to bare command string
  • PromptStep: short-circuits with synthetic preview message
  • GateStep: short-circuits with deterministic first-non-sentinel choice via _coerce_options\ / _first_non_sentinel\ helpers

CLI

  • \specify workflow run --dry-run\ flag with preview rendering after execution
  • --json\ suppresses dry-run banner (clean JSON stdout contract)
  • _print_dry_run_previews()\ shared between \workflow run\ and \workflow resume\

Tests

  • \ ests/test_dry_run.py: 16 tests covering CommandStep/PromptStep/GateStep dry-run paths, engine execution, resume, and CLI integration

Closes: #2661
Ref: #2704 (closed per maintainer request to split into smaller PRs)

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)
@fuleinist fuleinist requested a review from mnriem as a code owner June 23, 2026 16:25
Copilot AI review requested due to automatic review settings June 23, 2026 16:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_run across WorkflowEngine.execute(...), RunState.save/load, and resume().
  • Add dry-run short-circuit behavior to CommandStep, PromptStep, and GateStep.
  • Add specify workflow run --dry-run CLI 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.

Comment thread src/specify_cli/workflows/steps/gate/__init__.py
Comment thread src/specify_cli/__init__.py
Comment thread src/specify_cli/__init__.py Outdated
Comment thread src/specify_cli/__init__.py
Comment thread src/specify_cli/workflows/base.py Outdated
Comment thread src/specify_cli/__init__.py Outdated

@mnriem mnriem left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 7/7 changed files
  • Comments generated: 9

Comment on lines +61 to +74
# Dry-run short-circuit — emit a synthetic preview without
# dispatching to the integration CLI.
if context.dry_run:
preview = f"DRY RUN: would prompt integration {integration!r}: {prompt!r}"
output["exit_code"] = 0
output["dispatched"] = False
output["executed"] = False
output["dry_run"] = True
output["dry_run_message"] = preview
# Preserve the original prompt so
# ``{{ steps.<id>.output.message }}`` keeps resolving to the
# original prompt text for downstream templates.
output["message"] = preview
return StepResult(status=StepStatus.COMPLETED, output=output)
Comment on lines +102 to +111
output["exit_code"] = 0
output["dispatched"] = False
output["executed"] = False
output["dry_run"] = True
output["dry_run_message"] = preview
# Preserve the original command/integration/input so
# ``{{ steps.<id>.output.message }}`` keeps resolving to the
# original command description for downstream templates.
output["message"] = preview
output["invoke_command"] = preview_invocation or command
Comment on lines 40 to 45
if isinstance(message, str) and "{{" in message:
message = evaluate_expression(message, context)

options = config.get("options", ["approve", "reject"])
options = self._coerce_options(config.get("options", ["approve", "reject"]))
on_reject = config.get("on_reject", "abort")

Comment on lines +139 to +142
for opt in options:
if opt not in ("reject", "abort"):
return opt
return options[0]
Comment on lines +77 to +89
#: When ``True``, the built-in step implementations
#: (``command`` / ``prompt`` / ``gate``) short-circuit and return a
#: synthetic ``StepResult`` carrying a preview of what would have
#: been dispatched — no subprocess, no CLI call, no network I/O for
#: those step types. Custom steps and built-in steps that have not
#: been updated to honor ``dry_run`` may still perform their normal
#: side effects; the flag is opt-in per step. Step implementations
#: publish the preview on ``output["dry_run_message"]`` (consumed
#: by the CLI's preview loop). ``output["message"]`` is also set to
#: the preview string during dry-run (it may differ from the real
#: run's ``message``). Downstream templates that need the original
#: value should reference the step's pre-dispatch config fields
#: (e.g. ``command``, ``prompt``) instead of ``output.message``.
Comment on lines +872 to +876
if dry_run and not json_output:
console.print(
"\n[bold yellow]DRY RUN:[/bold yellow] previewing without "
"dispatching any AI or shell commands."
)
Comment on lines 885 to 887
except ValueError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
Comment on lines +953 to +962
def _escape_markup(text: str) -> str:
"""Escape Rich markup characters so a step ID can be printed safely.

Step IDs are user-controlled YAML; without escaping, an ID
containing ``[`` or ``]`` would raise ``MarkupError`` from Rich.
"""
return (
text.replace("[", "\\[")
.replace("]", "\\]")
)
Comment on lines +926 to +934
def _print_dry_run_previews(state: Any) -> None:
"""Print the dry-run preview message emitted by each step.

Called by ``workflow run`` after a successful dry-run and from
exception handlers so a mid-run failure still surfaces the
previews resolved by earlier steps. Skipped silently when
``state`` is ``None`` (e.g. the engine raised before any step
ran) or when the run did not include a dry-run step.
"""
@mnriem

mnriem commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Add dry-run flag to preview spec output without AI invocation

3 participants