mirror of
https://github.com/github/spec-kit.git
synced 2026-07-07 06:35:06 +08:00
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>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user