From 5217206fdfde2886482541897418781f7d54aa1f Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Tue, 7 Jul 2026 02:48:33 +0500 Subject: [PATCH] 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) --- .../workflows/steps/gate/__init__.py | 9 +++- tests/test_workflows.py | 42 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/workflows/steps/gate/__init__.py b/src/specify_cli/workflows/steps/gate/__init__.py index e07b6ebd6..0c9399ce3 100644 --- a/src/specify_cli/workflows/steps/gate/__init__.py +++ b/src/specify_cli/workflows/steps/gate/__init__.py @@ -73,7 +73,14 @@ class GateStep(StepBase): 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( diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 8fcf5b49c..11338b300 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -4060,6 +4060,48 @@ steps: 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