Skip to content
Open
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
18 changes: 16 additions & 2 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2756,6 +2756,16 @@ def _workflow_run_payload(state: Any) -> dict[str, Any]:
}


def _run_outcome_exit_code(status_value: str) -> int:
"""Exit code for a finished run/resume: non-zero on terminal failure.

``failed`` and ``aborted`` map to 1 so scripts and orchestrators can
rely on the process exit code; ``completed`` and ``paused`` map to 0
(paused is a legitimate waiting state, not a failure).
"""
return 1 if status_value in ("failed", "aborted") else 0


def _emit_workflow_json(payload: dict[str, Any]) -> None:
"""Write a workflow payload as machine-readable JSON to stdout.

Expand Down Expand Up @@ -2868,7 +2878,7 @@ def workflow_run(

if json_output:
_emit_workflow_json(_workflow_run_payload(state))
return
raise typer.Exit(_run_outcome_exit_code(state.status.value))

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

raise typer.Exit(_run_outcome_exit_code(state.status.value))


@workflow_app.command("resume")
def workflow_resume(
Expand Down Expand Up @@ -2921,7 +2933,7 @@ def workflow_resume(

if json_output:
_emit_workflow_json(_workflow_run_payload(state))
return
raise typer.Exit(_run_outcome_exit_code(state.status.value))

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

raise typer.Exit(_run_outcome_exit_code(state.status.value))


@workflow_app.command("status")
def workflow_status(
Expand Down
4 changes: 3 additions & 1 deletion tests/test_workflow_run_without_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,9 @@ def test_workflow_run_failing_yaml_without_project(self, tmp_path):
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"workflow run failed unexpectedly: {result.output}"
# A failed workflow now maps to a non-zero process exit code so
# scripts and CI can rely on $? (the CLI itself still ran fine).
assert result.exit_code == 1, f"expected exit 1 on failed run: {result.output}"
assert "Status: failed" in result.output

def test_workflow_run_yaml_rejects_symlinked_specify_dir(self, tmp_path):
Expand Down
68 changes: 68 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -3944,3 +3944,71 @@ def fake_open_url(url, timeout=None, extra_headers=None):
asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url]
assert len(asset_calls) >= 1
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}


class TestWorkflowRunExitCodes:
"""CLI-level tests for the run/resume process exit codes."""

_WF_OK = """
schema_version: "1.0"
workflow:
id: "exit-ok"
name: "Exit OK"
version: "1.0.0"
steps:
- id: fine
type: shell
run: "true"
"""

_WF_FAIL = """
schema_version: "1.0"
workflow:
id: "exit-fail"
name: "Exit Fail"
version: "1.0.0"
steps:
- id: boom
type: shell
run: "false"
"""

def _write(self, tmp_path, content):
path = tmp_path / "wf.yml"
path.write_text(content, encoding="utf-8")
return path

def test_run_completed_exits_zero(self, tmp_path, monkeypatch):
from typer.testing import CliRunner
from specify_cli import app

monkeypatch.chdir(tmp_path)
runner = CliRunner()
result = runner.invoke(app, ["workflow", "run", str(self._write(tmp_path, self._WF_OK))])
assert result.exit_code == 0
assert "Status: completed" in result.stdout

def test_run_failed_exits_nonzero(self, tmp_path, monkeypatch):
from typer.testing import CliRunner
from specify_cli import app

monkeypatch.chdir(tmp_path)
runner = CliRunner()
result = runner.invoke(app, ["workflow", "run", str(self._write(tmp_path, self._WF_FAIL))])
assert "Status: failed" in result.stdout
assert result.exit_code == 1

def test_run_failed_exits_nonzero_with_json(self, tmp_path, monkeypatch):
import json as _json
from typer.testing import CliRunner
from specify_cli import app

monkeypatch.chdir(tmp_path)
runner = CliRunner()
result = runner.invoke(
app,
["workflow", "run", str(self._write(tmp_path, self._WF_FAIL)), "--json"],
)
payload = _json.loads(result.stdout)
assert payload["status"] == "failed"
assert result.exit_code == 1