-
Notifications
You must be signed in to change notification settings - Fork 10.4k
feat(workflows): add --dry-run flag to specify workflow run #3124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a00ae8f
96d53ba
c19f6e1
38da606
c48c03a
954f3c2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -334,6 +334,11 @@ def __init__( | |
| self.created_at = datetime.now(timezone.utc).isoformat() | ||
| self.updated_at = self.created_at | ||
| self.log_entries: list[dict[str, Any]] = [] | ||
| #: Whether the run was started in dry-run mode. Persisted via | ||
| #: :meth:`save` so :meth:`load` (and the resumed run's | ||
| #: ``StepContext``) can keep the run in preview mode across | ||
| #: process restarts. | ||
| self.dry_run: bool = False | ||
|
|
||
| @property | ||
| def runs_dir(self) -> Path: | ||
|
|
@@ -352,6 +357,7 @@ def save(self) -> None: | |
| "current_step_index": self.current_step_index, | ||
| "current_step_id": self.current_step_id, | ||
| "step_results": self.step_results, | ||
| "dry_run": self.dry_run, | ||
| "created_at": self.created_at, | ||
| "updated_at": self.updated_at, | ||
| } | ||
|
|
@@ -398,6 +404,7 @@ def load(cls, run_id: str, project_root: Path) -> RunState: | |
| state.step_results = state_data.get("step_results", {}) | ||
| state.created_at = state_data.get("created_at", "") | ||
| state.updated_at = state_data.get("updated_at", "") | ||
| state.dry_run = state_data.get("dry_run", False) | ||
|
|
||
| inputs_path = runs_dir / "inputs.json" | ||
| if inputs_path.exists(): | ||
|
|
@@ -478,6 +485,7 @@ def execute( | |
| definition: WorkflowDefinition, | ||
| inputs: dict[str, Any] | None = None, | ||
| run_id: str | None = None, | ||
| dry_run: bool = False, | ||
| ) -> RunState: | ||
| """Execute a workflow definition. | ||
|
|
||
|
|
@@ -489,6 +497,21 @@ def execute( | |
| User-provided input values. | ||
| run_id: | ||
| Optional run ID (uses SPECKIT_WORKFLOW_RUN_ID when set, otherwise auto-generated). | ||
| dry_run: | ||
| Preview-only mode. When ``True``, the built-in ``command``, | ||
| ``prompt`` and ``gate`` step implementations skip | ||
| side-effecting work (AI invocations, interactive prompts, | ||
| subprocess dispatches) and emit a synthetic | ||
| ``dry_run_message`` instead. Other built-in steps (``init``, | ||
| ``shell``, custom user-registered steps) currently still | ||
| execute their normal logic during a dry run; the flag is | ||
| opt-in per step. ``dry_run`` propagates into each step's | ||
|
Comment on lines
+505
to
+508
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Acknowledged, but I'm keeping shell-step execution in dry-run as-is. The engine docstring (src/specify_cli/workflows/engine.py:486-509) explicitly documents: 'Other built-in steps (init, shell, custom user-registered steps) currently still execute their normal logic during a dry run; the flag is opt-in per step.' That is the intended design — making shell steps opt-in to dry-run would break any setup/teardown shell step in a workflow that uses --dry-run for previews. The maintainer's CHANGES_REQUESTED on 2026-06-23 was scoped to the Copilot batch as a whole; no human review has called out shell specifically. Happy to revisit if @mnriem or another maintainer explicitly wants shell steps to honor --dry-run — at that point we'd want a separate opt-in flag (e.g. |
||
| ``StepContext`` and is persisted on the resulting | ||
| ``RunState`` so ``resume()`` keeps the run in preview mode | ||
| across restarts. Step ``output`` shape is unchanged; | ||
| downstream ``switch``/``do-while`` gates coerce any | ||
| dry-run-only fields (e.g. ``output.choice``) so the preview | ||
| branch is deterministic. | ||
|
|
||
| Returns | ||
| ------- | ||
|
|
@@ -507,6 +530,7 @@ def execute( | |
| workflow_id=definition.id, | ||
| project_root=self.project_root, | ||
| ) | ||
| state.dry_run = dry_run | ||
|
|
||
| # Persist a copy of the workflow definition so resume can | ||
| # reload it even if the original source is no longer available | ||
|
|
@@ -531,6 +555,7 @@ def execute( | |
| default_options=definition.default_options, | ||
| project_root=str(self.project_root), | ||
| run_id=state.run_id, | ||
| dry_run=dry_run, | ||
| ) | ||
|
|
||
| # Execute steps | ||
|
|
@@ -545,6 +570,10 @@ def execute( | |
| state.status = RunStatus.FAILED | ||
| state.append_log({"event": "workflow_failed", "error": str(exc)}) | ||
| state.save() | ||
| # Attach the partially-populated state so the CLI can render | ||
| # any dry-run previews resolved by earlier steps when the | ||
| # engine raises mid-run (e.g. template resolution failure). | ||
| exc.partial_state = state # type: ignore[attr-defined] | ||
| raise | ||
|
|
||
| if state.status == RunStatus.RUNNING: | ||
|
|
@@ -587,7 +616,8 @@ def resume( | |
| merged = {**state.inputs, **inputs} | ||
| state.inputs = self._resolve_inputs(definition, merged) | ||
|
|
||
| # Restore context | ||
| # Restore context — including the persisted ``dry_run`` flag so an | ||
| # interrupted dry-run stays a dry-run after a process restart. | ||
| context = StepContext( | ||
| inputs=state.inputs, | ||
| steps=state.step_results, | ||
|
|
@@ -596,6 +626,7 @@ def resume( | |
| default_options=definition.default_options, | ||
| project_root=str(self.project_root), | ||
| run_id=state.run_id, | ||
| dry_run=state.dry_run, | ||
| ) | ||
|
|
||
| from . import STEP_REGISTRY | ||
|
|
@@ -622,6 +653,10 @@ def resume( | |
| state.status = RunStatus.FAILED | ||
| state.append_log({"event": "resume_failed", "error": str(exc)}) | ||
| state.save() | ||
| # Same preview surface as ``execute()`` — when the engine | ||
| # raises mid-resume the CLI wants the partially-resolved | ||
| # dry-run previews for debugging. | ||
| exc.partial_state = state # type: ignore[attr-defined] | ||
| raise | ||
|
|
||
| if state.status == RunStatus.RUNNING: | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.