Skip to content

Commit 751eae7

Browse files
jawwad-aliclaude
andauthored
fix(workflows): escape the step-progress line so step ids render (and / stops failing the run) (#3783)
`workflow run` and `workflow resume` both print the step-progress line as `f" ▸ [{sid}] {label} …"`. Rich parses the bracketed step id as a style tag, which produces three failures on main: 1. The id is SILENTLY SWALLOWED on every run -- the only identifying content on the line. `id: greet` prints " ▸ shell …"; "[greet]" is absent. 2. An id that forms a closing tag FAILS THE WHOLE RUN. `validate_workflow` places no charset restriction on step ids, so `id: "/"` is a valid workflow; the callback then raises MarkupError, which propagates into execute()'s handler -> run persisted as `failed` with empty `step_results`, the step never executed, exit 1 with a Rich internals error. 3. An id that is a real style (`bold`, `red`) is applied as FORMATTING to the rest of the line. The unescaped `label` (from `step_config["command"]`) compounds it. Escape the literal bracket with `\[` and escape both interpolated values, at both sites. This mirrors the `\[<type>]` step-graph precedent already in this file (workflow_info). Escaping only the values is NOT sufficient -- the f-string's own brackets are what Rich consumes. Verified through the real CLI: ids `greet`/`bold`/`a]b` now render verbatim, and `id: "/"` goes from a failed run to `Status: completed`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4ad7ef2 commit 751eae7

2 files changed

Lines changed: 110 additions & 2 deletions

File tree

src/specify_cli/workflows/_commands.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,7 +1054,18 @@ def workflow_run(
10541054
load_custom_steps(project_root)
10551055
engine = WorkflowEngine(project_root)
10561056
if not json_output:
1057-
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
1057+
# Escape the literal bracket (\[) so Rich renders `[<step id>]` instead
1058+
# of parsing it as a style tag named after the step id -- which it
1059+
# silently swallows (losing the only identifying content on the line),
1060+
# applies as formatting when the id happens to be a real style such as
1061+
# `bold`, or raises MarkupError when the id forms a closing tag (`/`),
1062+
# failing the whole run. Escape the interpolated values too, since both
1063+
# come from workflow YAML. Mirrors the `\[<type>]` step-graph precedent
1064+
# in workflow_info below.
1065+
engine.on_step_start = lambda sid, label: console.print(
1066+
f" \u25b8 \\[{_escape_markup(str(sid))}] "
1067+
f"{_escape_markup(str(label))} \u2026"
1068+
)
10581069

10591070
err = _error_console(json_output)
10601071

@@ -1176,7 +1187,18 @@ def workflow_resume(
11761187
load_custom_steps(project_root)
11771188
engine = WorkflowEngine(project_root)
11781189
if not json_output:
1179-
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
1190+
# Escape the literal bracket (\[) so Rich renders `[<step id>]` instead
1191+
# of parsing it as a style tag named after the step id -- which it
1192+
# silently swallows (losing the only identifying content on the line),
1193+
# applies as formatting when the id happens to be a real style such as
1194+
# `bold`, or raises MarkupError when the id forms a closing tag (`/`),
1195+
# failing the whole run. Escape the interpolated values too, since both
1196+
# come from workflow YAML. Mirrors the `\[<type>]` step-graph precedent
1197+
# in workflow_info below.
1198+
engine.on_step_start = lambda sid, label: console.print(
1199+
f" \u25b8 \\[{_escape_markup(str(sid))}] "
1200+
f"{_escape_markup(str(label))} \u2026"
1201+
)
11801202

11811203
inputs = _parse_input_values(input_values, json_output=json_output)
11821204
err = _error_console(json_output)

tests/test_workflows.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9744,6 +9744,92 @@ def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None
97449744
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
97459745

97469746

9747+
class TestWorkflowStepStartProgressLine:
9748+
"""The `run`/`resume` step-progress line must render the step id literally.
9749+
9750+
The line is built as ` ▸ [<id>] <label> …`, so Rich parsed the bracketed id
9751+
as a style tag: it silently swallowed the id (the only identifying content
9752+
on the line), applied it as formatting when the id happened to be a real
9753+
style like `bold`, and raised MarkupError — failing the whole run — when the
9754+
id formed a closing tag such as `/`. `validate_workflow` places no charset
9755+
restriction on step ids, so all of these are accepted workflows.
9756+
"""
9757+
9758+
def _write(self, tmp_path, step_id):
9759+
path = tmp_path / "wf.yml"
9760+
path.write_text(
9761+
'schema_version: "1.0"\n'
9762+
"workflow:\n"
9763+
' id: "probe-wf"\n'
9764+
' name: "Probe"\n'
9765+
' version: "1.0.0"\n'
9766+
"steps:\n"
9767+
f' - id: "{step_id}"\n'
9768+
" type: shell\n"
9769+
' run: "exit 0"\n',
9770+
encoding="utf-8",
9771+
)
9772+
return path
9773+
9774+
@pytest.mark.parametrize("step_id", ["greet", "bold", "a]b"])
9775+
def test_progress_line_shows_step_id(self, tmp_path, monkeypatch, step_id):
9776+
from typer.testing import CliRunner
9777+
from specify_cli import app
9778+
9779+
monkeypatch.chdir(tmp_path)
9780+
result = CliRunner().invoke(
9781+
app, ["workflow", "run", str(self._write(tmp_path, step_id))]
9782+
)
9783+
assert result.exit_code == 0, result.stdout
9784+
assert f"[{step_id}]" in result.stdout
9785+
9786+
def test_step_id_forming_a_closing_tag_does_not_fail_the_run(
9787+
self, tmp_path, monkeypatch
9788+
):
9789+
"""`id: "/"` raised MarkupError from inside the progress callback, which
9790+
surfaced as a failed run with no step results."""
9791+
from typer.testing import CliRunner
9792+
from specify_cli import app
9793+
9794+
monkeypatch.chdir(tmp_path)
9795+
result = CliRunner().invoke(
9796+
app, ["workflow", "run", str(self._write(tmp_path, "/"))]
9797+
)
9798+
assert result.exit_code == 0, result.stdout
9799+
assert "Status: completed" in result.stdout
9800+
assert "[/]" in result.stdout
9801+
9802+
def test_resume_progress_line_shows_step_id(self, tmp_path, monkeypatch):
9803+
"""`workflow resume` installs its own copy of the same callback, so it
9804+
needs independent coverage — a one-line fix would miss the twin."""
9805+
import json as _json
9806+
9807+
from typer.testing import CliRunner
9808+
from specify_cli import app
9809+
9810+
monkeypatch.chdir(tmp_path)
9811+
path = tmp_path / "wf.yml"
9812+
path.write_text(
9813+
'schema_version: "1.0"\n'
9814+
"workflow:\n"
9815+
' id: "probe-resume"\n'
9816+
' name: "Probe"\n'
9817+
' version: "1.0.0"\n'
9818+
"steps:\n"
9819+
" - id: boom\n"
9820+
" type: shell\n"
9821+
' run: "exit 1"\n',
9822+
encoding="utf-8",
9823+
)
9824+
runner = CliRunner()
9825+
first = runner.invoke(app, ["workflow", "run", str(path), "--json"])
9826+
run_id = _json.loads(first.stdout).get("run_id")
9827+
assert run_id
9828+
9829+
resumed = runner.invoke(app, ["workflow", "resume", run_id])
9830+
assert "[boom]" in resumed.stdout
9831+
9832+
97479833
class TestWorkflowRunExitCodes:
97489834
"""CLI-level tests for the run/resume process exit codes."""
97499835

0 commit comments

Comments
 (0)