Skip to content
Merged
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
92 changes: 92 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -3944,3 +3944,95 @@ 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."""

Comment thread
mnriem marked this conversation as resolved.
_WF_OK = """
schema_version: "1.0"
workflow:
id: "exit-ok"
name: "Exit OK"
version: "1.0.0"
steps:
- id: fine
type: shell
run: "exit 0"
"""

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

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"],
)
assert result.exit_code == 1, result.stdout
payload = _json.loads(result.stdout)
assert payload["status"] == "failed"

def test_resume_failed_run_exits_nonzero(self, tmp_path, monkeypatch):
# End-to-end coverage for the `workflow resume` exit-code mapping:
# resuming a run whose outcome is still `failed` must exit non-zero,
# mirroring `workflow run`. Resume re-executes the failed step, which
# fails again, so the resumed outcome stays `failed`.
import json as _json
from typer.testing import CliRunner
from specify_cli import app

monkeypatch.chdir(tmp_path)
(tmp_path / ".specify").mkdir() # `workflow resume` requires a project
runner = CliRunner()
run = runner.invoke(
app,
["workflow", "run", str(self._write(tmp_path, self._WF_FAIL)), "--json"],
)
assert run.exit_code == 1, run.stdout
run_id = _json.loads(run.stdout)["run_id"]

resumed = runner.invoke(app, ["workflow", "resume", run_id, "--json"])
assert resumed.exit_code == 1, resumed.stdout
payload = _json.loads(resumed.stdout)
assert payload["status"] == "failed"
Loading