-
Notifications
You must be signed in to change notification settings - Fork 9.7k
feat(workflows): add --dry-run flag to specify workflow run #2704
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
721ef9a
4954c1c
b42dbde
7f717e0
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 |
|---|---|---|
|
|
@@ -53,12 +53,6 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: | |
| if step_options: | ||
| options.update(step_options) | ||
|
|
||
| # Attempt CLI dispatch | ||
| args_str = str(resolved_input.get("args", "")) | ||
| dispatch_result = self._try_dispatch( | ||
| command, integration, model, args_str, context | ||
| ) | ||
|
|
||
| output: dict[str, Any] = { | ||
| "command": command, | ||
| "integration": integration, | ||
|
|
@@ -67,6 +61,61 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: | |
| "input": resolved_input, | ||
| } | ||
|
|
||
| # Dry-run: show the rendered prompt without invoking the AI | ||
| if context.dry_run: | ||
| args_str = str(resolved_input.get("args", "")) | ||
| # Use the integration's own build_command_invocation() so the | ||
| # preview matches exactly what would be dispatched at runtime | ||
| invoke_str = f"{command} {args_str}".strip() if command else args_str | ||
| preview_note: str | None = None | ||
| if integration: | ||
| try: | ||
| from specify_cli.integrations import get_integration | ||
| impl = get_integration(integration) | ||
| if impl is not None: | ||
| invoke_str = impl.build_command_invocation(command, args_str) | ||
| except (ImportError, AttributeError, KeyError, TypeError, ValueError) as exc: | ||
| # ``build_command_invocation`` is optional in the | ||
| # integration protocol — fall back to ``<command> <args>`` | ||
| # rather than swallowing the error silently. Record the | ||
| # reason so dry-run output makes the fallback explicit. | ||
| preview_note = ( | ||
| f"(integration {integration!r} did not provide " | ||
| f"build_command_invocation: {type(exc).__name__}: {exc})" | ||
| ) | ||
| output["dispatched"] = False | ||
| output["dry_run"] = True | ||
| # ``executed=False`` lets downstream branching/conditions | ||
| # distinguish a dry-run preview from a real successful run. | ||
| # ``exit_code`` is kept at 0 for backward compatibility | ||
| # (and because the step status is COMPLETED), but consumers | ||
| # that need to key on "did the integration actually run?" | ||
| # should check ``executed`` rather than ``exit_code``. | ||
| output["executed"] = False | ||
| output["exit_code"] = 0 | ||
| output["stdout"] = "" | ||
| output["stderr"] = "" | ||
| output["invoke_command"] = invoke_str | ||
|
fuleinist marked this conversation as resolved.
Comment on lines
+86
to
+98
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. Fixed in 7f717e0. CommandStep's dry-run branch now sets |
||
| message_body = ( | ||
| f"[DRY RUN] Command: {invoke_str}\n" | ||
| f" Integration: {integration}\n" | ||
| f" Model: {model}\n" | ||
|
fuleinist marked this conversation as resolved.
|
||
| f" (AI invocation skipped — use without --dry-run to execute)" | ||
| ) | ||
| if preview_note: | ||
| message_body += f"\n {preview_note}" | ||
| output["message"] = message_body | ||
| return StepResult( | ||
| status=StepStatus.COMPLETED, | ||
| output=output, | ||
| ) | ||
|
|
||
| # Attempt CLI dispatch | ||
| args_str = str(resolved_input.get("args", "")) | ||
| dispatch_result = self._try_dispatch( | ||
| command, integration, model, args_str, context | ||
| ) | ||
|
|
||
| if dispatch_result is not None: | ||
| output["exit_code"] = dispatch_result["exit_code"] | ||
| output["stdout"] = dispatch_result["stdout"] | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -40,7 +40,22 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: | |
| if isinstance(message, str) and "{{" in message: | ||
| message = evaluate_expression(message, context) | ||
|
|
||
| options = config.get("options", ["approve", "reject"]) | ||
| # Normalize ``options`` defensively: workflows that bypass | ||
| # validation may set it to a non-sequence (string, dict, scalar). | ||
| # Without this guard, ``options[0]`` in the dry-run branch | ||
| # would index into a string (returning a single character) or | ||
| # raise on a dict. Accept any ``Sequence`` (list, tuple, etc.) | ||
| # other than ``str`` (which is itself a Sequence of chars but | ||
| # never a meaningful list of gate options). | ||
| import collections.abc | ||
|
|
||
| raw_options = config.get("options", ["approve", "reject"]) | ||
| if isinstance(raw_options, collections.abc.Sequence) and not isinstance( | ||
| raw_options, (str, bytes) | ||
| ): | ||
| options: list[str] = [str(o) for o in raw_options if o is not None] | ||
| else: | ||
| options = [] | ||
|
Comment on lines
+52
to
+58
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. Fixed in 7f717e0. GateStep's options normalization now accepts any |
||
| on_reject = config.get("on_reject", "abort") | ||
|
|
||
| show_file = config.get("show_file") | ||
|
|
@@ -61,6 +76,39 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: | |
| "choice": None, | ||
| } | ||
|
|
||
| # Dry-run: skip interactive gates | ||
| if context.dry_run: | ||
| output["dry_run"] = True | ||
| # Pick a choice that won't unintentionally steer downstream | ||
| # branching. If the first option is a reject/abort sentinel | ||
| # (i.e. an option that would fail the gate when chosen for | ||
| # real), skip it; otherwise the first option is safe enough | ||
| # to preview. If no safe option exists, leave ``choice`` as | ||
| # ``None`` so downstream ``{{ steps.<id>.output.choice }}`` | ||
| # expressions see a neutral value. | ||
| reject_sentinels = {"reject", "abort"} | ||
| safe_choice = next( | ||
| (opt for opt in options if opt.lower() not in reject_sentinels), | ||
| None, | ||
| ) | ||
| output["choice"] = safe_choice | ||
| # Preserve the original ``message`` so downstream steps | ||
| # that reference ``{{ steps.<id>.output.message }}`` still | ||
| # see the prompt text. The DRY RUN preview is published | ||
| # on a separate ``dry_run_message`` field that the CLI | ||
| # rendering loop reads (with a fallback to ``message`` | ||
| # for custom step types that have not adopted the new | ||
| # convention). | ||
| output["dry_run_message"] = ( | ||
| f"[DRY RUN] Gate: {message}\n" | ||
| f" Options: {options}\n" | ||
|
fuleinist marked this conversation as resolved.
|
||
| f" (interactive prompt skipped — use without --dry-run to gate)" | ||
| ) | ||
|
fuleinist marked this conversation as resolved.
fuleinist marked this conversation as resolved.
|
||
| return StepResult( | ||
| status=StepStatus.COMPLETED, | ||
| output=output, | ||
| ) | ||
|
fuleinist marked this conversation as resolved.
Comment on lines
+79
to
+110
Comment on lines
+79
to
+110
mnriem marked this conversation as resolved.
mnriem marked this conversation as resolved.
|
||
|
|
||
| # Non-interactive: pause for later resume (the file is not read here) | ||
| if not sys.stdin.isatty(): | ||
| return StepResult(status=StepStatus.PAUSED, output=output) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.