Skip to content

Commit 5217206

Browse files
fix(workflows): match gate reject option case-insensitively (#3335)
`validate` accepts a reject option case-insensitively (`o.lower() in {"reject", "abort"}`), so a gate authored as `options: [Approve, Reject]` passes validation. But `execute` compared the echoed choice case-sensitively, so picking `Reject` fell through to the approval path and silently ran downstream steps instead of aborting. Lower-case `choice` before the reject comparison so the runtime agrees with the validation that let the option through. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c978faa commit 5217206

2 files changed

Lines changed: 50 additions & 1 deletion

File tree

src/specify_cli/workflows/steps/gate/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,14 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
7373
choice = self._prompt(self._compose_prompt(message, show_file), options)
7474
output["choice"] = choice
7575

76-
if choice in ("reject", "abort"):
76+
# Match rejection case-insensitively. ``_prompt`` echoes the option's
77+
# original casing, and ``validate`` accepts a reject option
78+
# case-insensitively (``o.lower() in {"reject", "abort"}``), so a gate
79+
# authored as ``options: [Approve, Reject]`` passes validation. Comparing
80+
# ``choice`` case-sensitively here would then treat a ``Reject`` pick as
81+
# approval and silently skip the abort — the reject path must agree with
82+
# the check that let the option through.
83+
if choice.lower() in ("reject", "abort"):
7784
if on_reject == "abort":
7885
output["aborted"] = True
7986
return StepResult(

tests/test_workflows.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4060,6 +4060,48 @@ def test_gate_abort_still_halts_with_continue_on_error(
40604060
assert state.status == RunStatus.ABORTED
40614061
assert "should-not-run" not in state.step_results
40624062

4063+
def test_gate_reject_matches_case_insensitively(
4064+
self, project_dir, monkeypatch
4065+
):
4066+
"""A capitalised reject option (`options: [Approve, Reject]`) still
4067+
aborts the run. `validate` accepts a reject choice case-insensitively,
4068+
so the runtime reject check must agree — a case-sensitive comparison
4069+
would treat the echoed `Reject` as approval and silently run
4070+
downstream steps.
4071+
"""
4072+
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
4073+
from specify_cli.workflows.base import RunStatus
4074+
from specify_cli.workflows.steps.gate import GateStep
4075+
4076+
# `_prompt` echoes the option's original casing, so the operator
4077+
# picking "Reject" hands `execute` the capitalised string.
4078+
_force_gate_stdin(monkeypatch, tty=True)
4079+
monkeypatch.setattr(
4080+
GateStep, "_prompt", staticmethod(lambda _msg, _opts: "Reject")
4081+
)
4082+
4083+
definition = WorkflowDefinition.from_string("""
4084+
schema_version: "1.0"
4085+
workflow:
4086+
id: "gate-reject-case"
4087+
name: "Gate Reject Case"
4088+
version: "1.0.0"
4089+
steps:
4090+
- id: gate-step
4091+
type: gate
4092+
message: "Approve?"
4093+
options: [Approve, Reject]
4094+
on_reject: abort
4095+
- id: should-not-run
4096+
type: shell
4097+
run: "echo nope"
4098+
""")
4099+
engine = WorkflowEngine(project_dir)
4100+
state = engine.execute(definition)
4101+
4102+
assert state.status == RunStatus.ABORTED
4103+
assert "should-not-run" not in state.step_results
4104+
40634105
def test_validation_rejects_non_bool_continue_on_error(self):
40644106
"""`continue_on_error` must be a literal boolean; coerced
40654107
strings like `"true"` are rejected at validation time so

0 commit comments

Comments
 (0)