Skip to content

Commit df51dea

Browse files
committed
fix: non-zero exit code when a workflow run ends failed or aborted
workflow run and workflow resume printed Status: failed (or emitted the --json payload) and exited 0, so scripts and CI could not rely on the process exit code. Map terminal outcomes: failed|aborted -> 1, completed|paused -> 0, on both the text and --json paths. The previous exit-0-on-failed behavior was pinned by test_workflow_run_failing_yaml_without_project; the pin is updated to the new contract. Fixes #2958
1 parent 1b0556c commit df51dea

3 files changed

Lines changed: 87 additions & 3 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2756,6 +2756,16 @@ def _workflow_run_payload(state: Any) -> dict[str, Any]:
27562756
}
27572757

27582758

2759+
def _run_outcome_exit_code(status_value: str) -> int:
2760+
"""Exit code for a finished run/resume: non-zero on terminal failure.
2761+
2762+
``failed`` and ``aborted`` map to 1 so scripts and orchestrators can
2763+
rely on the process exit code; ``completed`` and ``paused`` map to 0
2764+
(paused is a legitimate waiting state, not a failure).
2765+
"""
2766+
return 1 if status_value in ("failed", "aborted") else 0
2767+
2768+
27592769
def _emit_workflow_json(payload: dict[str, Any]) -> None:
27602770
"""Write a workflow payload as machine-readable JSON to stdout.
27612771
@@ -2868,7 +2878,7 @@ def workflow_run(
28682878

28692879
if json_output:
28702880
_emit_workflow_json(_workflow_run_payload(state))
2871-
return
2881+
raise typer.Exit(_run_outcome_exit_code(state.status.value))
28722882

28732883
status_colors = {
28742884
"completed": "green",
@@ -2883,6 +2893,8 @@ def workflow_run(
28832893
if state.status.value == "paused":
28842894
console.print(f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]")
28852895

2896+
raise typer.Exit(_run_outcome_exit_code(state.status.value))
2897+
28862898

28872899
@workflow_app.command("resume")
28882900
def workflow_resume(
@@ -2921,7 +2933,7 @@ def workflow_resume(
29212933

29222934
if json_output:
29232935
_emit_workflow_json(_workflow_run_payload(state))
2924-
return
2936+
raise typer.Exit(_run_outcome_exit_code(state.status.value))
29252937

29262938
status_colors = {
29272939
"completed": "green",
@@ -2932,6 +2944,8 @@ def workflow_resume(
29322944
color = status_colors.get(state.status.value, "white")
29332945
console.print(f"\n[{color}]Status: {state.status.value}[/{color}]")
29342946

2947+
raise typer.Exit(_run_outcome_exit_code(state.status.value))
2948+
29352949

29362950
@workflow_app.command("status")
29372951
def workflow_status(

tests/test_workflow_run_without_project.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,9 @@ def test_workflow_run_failing_yaml_without_project(self, tmp_path):
162162
], catch_exceptions=False)
163163
finally:
164164
os.chdir(old_cwd)
165-
assert result.exit_code == 0, f"workflow run failed unexpectedly: {result.output}"
165+
# A failed workflow now maps to a non-zero process exit code so
166+
# scripts and CI can rely on $? (the CLI itself still ran fine).
167+
assert result.exit_code == 1, f"expected exit 1 on failed run: {result.output}"
166168
assert "Status: failed" in result.output
167169

168170
def test_workflow_run_yaml_rejects_symlinked_specify_dir(self, tmp_path):

tests/test_workflows.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3944,3 +3944,71 @@ def fake_open_url(url, timeout=None, extra_headers=None):
39443944
asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url]
39453945
assert len(asset_calls) >= 1
39463946
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
3947+
3948+
3949+
class TestWorkflowRunExitCodes:
3950+
"""CLI-level tests for the run/resume process exit codes."""
3951+
3952+
_WF_OK = """
3953+
schema_version: "1.0"
3954+
workflow:
3955+
id: "exit-ok"
3956+
name: "Exit OK"
3957+
version: "1.0.0"
3958+
steps:
3959+
- id: fine
3960+
type: shell
3961+
run: "true"
3962+
"""
3963+
3964+
_WF_FAIL = """
3965+
schema_version: "1.0"
3966+
workflow:
3967+
id: "exit-fail"
3968+
name: "Exit Fail"
3969+
version: "1.0.0"
3970+
steps:
3971+
- id: boom
3972+
type: shell
3973+
run: "false"
3974+
"""
3975+
3976+
def _write(self, tmp_path, content):
3977+
path = tmp_path / "wf.yml"
3978+
path.write_text(content, encoding="utf-8")
3979+
return path
3980+
3981+
def test_run_completed_exits_zero(self, tmp_path, monkeypatch):
3982+
from typer.testing import CliRunner
3983+
from specify_cli import app
3984+
3985+
monkeypatch.chdir(tmp_path)
3986+
runner = CliRunner()
3987+
result = runner.invoke(app, ["workflow", "run", str(self._write(tmp_path, self._WF_OK))])
3988+
assert result.exit_code == 0
3989+
assert "Status: completed" in result.stdout
3990+
3991+
def test_run_failed_exits_nonzero(self, tmp_path, monkeypatch):
3992+
from typer.testing import CliRunner
3993+
from specify_cli import app
3994+
3995+
monkeypatch.chdir(tmp_path)
3996+
runner = CliRunner()
3997+
result = runner.invoke(app, ["workflow", "run", str(self._write(tmp_path, self._WF_FAIL))])
3998+
assert "Status: failed" in result.stdout
3999+
assert result.exit_code == 1
4000+
4001+
def test_run_failed_exits_nonzero_with_json(self, tmp_path, monkeypatch):
4002+
import json as _json
4003+
from typer.testing import CliRunner
4004+
from specify_cli import app
4005+
4006+
monkeypatch.chdir(tmp_path)
4007+
runner = CliRunner()
4008+
result = runner.invoke(
4009+
app,
4010+
["workflow", "run", str(self._write(tmp_path, self._WF_FAIL)), "--json"],
4011+
)
4012+
payload = _json.loads(result.stdout)
4013+
assert payload["status"] == "failed"
4014+
assert result.exit_code == 1

0 commit comments

Comments
 (0)