fix: non-zero exit code when a workflow run ends failed or aborted (#2959)

* 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

* test: portable exit-code step commands + cover resume failed->exit-1

Address review (#2959): replace non-portable run: 'true'/'false' with 'exit 0'/'exit 1' (Windows cmd.exe has no true/false builtins under shell=True), and add an end-to-end 'workflow resume' test asserting a resumed failed run exits non-zero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Huy Do
2026-06-17 20:02:00 +07:00
committed by GitHub
parent 811a3aa447
commit 1ee2b626a8
3 changed files with 111 additions and 3 deletions

View File

@@ -2100,6 +2100,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.
@@ -2214,7 +2224,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",
@@ -2229,6 +2239,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(
@@ -2269,7 +2281,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",
@@ -2280,6 +2292,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(

View File

@@ -162,7 +162,9 @@ class TestWorkflowRunWithoutProject:
], 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):

View File

@@ -4964,3 +4964,95 @@ steps:
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: "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"