feat(workflows): make shell step timeout configurable (#3404)

* feat(workflows): make shell step timeout configurable

The shell step hardcoded a 300s subprocess timeout, killing any
legitimate long-running QA command. Read an optional timeout field
(seconds, positive integer, default 300) and validate it.

Fixes #3327

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* style: multi-line timeout validation, assert status in default test

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Guard against unvalidated timeout in ShellStep.execute()

The engine does not auto-validate step config, so a string or null
timeout would reach subprocess.run() and crash the run with a
TypeError. Fall back to the 300s default for malformed values,
mirroring how the engine treats unvalidated continue_on_error.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Marsel Safin
2026-07-09 18:06:45 +02:00
committed by GitHub
parent 292eaa6c98
commit eedf73f714
2 changed files with 122 additions and 2 deletions

View File

@@ -25,6 +25,14 @@ class ShellStep(StepBase):
run_cmd = str(run_cmd)
cwd = context.project_root or "."
# Defensive: the engine does not auto-validate step config, so an
# invalid ``timeout`` (string, None, ...) would otherwise raise a
# TypeError from subprocess.run() and crash the whole run. Mirror
# the engine's handling of unvalidated ``continue_on_error`` by
# only honoring well-formed values and falling back to the default.
timeout = config.get("timeout", 300)
if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0:
timeout = 300
# NOTE: shell=True is required to support pipes, redirects, and
# multi-command expressions in workflow YAML. Workflow authors
@@ -37,7 +45,7 @@ class ShellStep(StepBase):
capture_output=True,
text=True,
cwd=cwd,
timeout=300,
timeout=timeout,
)
output = {
"exit_code": proc.returncode,
@@ -74,7 +82,7 @@ class ShellStep(StepBase):
except subprocess.TimeoutExpired:
return StepResult(
status=StepStatus.FAILED,
error="Shell command timed out after 300 seconds.",
error=f"Shell command timed out after {timeout} seconds.",
output={"exit_code": -1, "stdout": "", "stderr": "timeout"},
)
except OSError as exc:
@@ -106,4 +114,16 @@ class ShellStep(StepBase):
f"Shell step {config.get('id', '?')!r}: 'output_format' must "
f"be 'json' when present, got {output_format!r}."
)
if "timeout" in config:
timeout = config["timeout"]
# bool is an int subclass, so reject it explicitly.
if (
isinstance(timeout, bool)
or not isinstance(timeout, int)
or timeout <= 0
):
errors.append(
f"Shell step {config.get('id', '?')!r}: 'timeout' must be a "
f"positive integer (seconds) when present, got {timeout!r}."
)
return errors