Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/specify_cli/workflows/steps/gate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,14 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
choice = self._prompt(self._compose_prompt(message, show_file), options)
output["choice"] = choice

if choice in ("reject", "abort"):
# Match rejection case-insensitively. ``_prompt`` echoes the option's
# original casing, and ``validate`` accepts a reject option
# case-insensitively (``o.lower() in {"reject", "abort"}``), so a gate
# authored as ``options: [Approve, Reject]`` passes validation. Comparing
# ``choice`` case-sensitively here would then treat a ``Reject`` pick as
# approval and silently skip the abort — the reject path must agree with
# the check that let the option through.
if choice.lower() in ("reject", "abort"):
if on_reject == "abort":
output["aborted"] = True
return StepResult(
Expand Down
42 changes: 42 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -3977,6 +3977,48 @@ def test_gate_abort_still_halts_with_continue_on_error(
assert state.status == RunStatus.ABORTED
assert "should-not-run" not in state.step_results

def test_gate_reject_matches_case_insensitively(
self, project_dir, monkeypatch
):
"""A capitalised reject option (`options: [Approve, Reject]`) still
aborts the run. `validate` accepts a reject choice case-insensitively,
so the runtime reject check must agree — a case-sensitive comparison
would treat the echoed `Reject` as approval and silently run
downstream steps.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
from specify_cli.workflows.base import RunStatus
from specify_cli.workflows.steps.gate import GateStep

# `_prompt` echoes the option's original casing, so the operator
# picking "Reject" hands `execute` the capitalised string.
_force_gate_stdin(monkeypatch, tty=True)
monkeypatch.setattr(
GateStep, "_prompt", staticmethod(lambda _msg, _opts: "Reject")
)

definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "gate-reject-case"
name: "Gate Reject Case"
version: "1.0.0"
steps:
- id: gate-step
type: gate
message: "Approve?"
options: [Approve, Reject]
on_reject: abort
- id: should-not-run
type: shell
run: "echo nope"
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)

assert state.status == RunStatus.ABORTED
assert "should-not-run" not in state.step_results

def test_validation_rejects_non_bool_continue_on_error(self):
"""`continue_on_error` must be a literal boolean; coerced
strings like `"true"` are rejected at validation time so
Expand Down
Loading