Skip to content

Commit 8a05221

Browse files
cowork-bot: automated improvements (cowork/improve-sha-pin-checkout-20260810) (#15)
* fix(ci): SHA-pin actions/checkout in cowork-auto-pr workflow The cowork-auto-pr.yml workflow used unpinned actions/checkout@v4 while all other workflows (ci.yml, publish.yml, release-audit.yml) use SHA-pinned versions. This creates supply-chain risk and inconsistency. Pinned to d23441a48e516b6c34aea4fa41551a30e30af803 (v6) to match the fleet standard. * test(ci): add SHA-pin and silent-failure regression tests - test_all_actions_sha_pinned: enforces 40-char SHA refs for all remote actions - test_no_silent_failure_on_validation_steps: catches '|| true' suppression on lint/test/audit steps (validation theater trap) Regression guard so future mutable-tag PRs are caught in CI. * cowork-bot: stream subprocess output in real time via Popen Switch dispatch from subprocess.run(capture_output=True) to subprocess.Popen with inherited file descriptors. The previous implementation buffered all child stdout/stderr in memory before printing, causing UX lag on long-running tools (deploydiff, schemaforge, configdrift) and potential OOM on large outputs. Popen streams output directly to the parent terminal. Added test_dispatch_streaming.py with regression guards against capture_output=True and stdout=PIPE. Updated existing dispatch tests to mock Popen instead of run. * cowork-bot: fix ruff lint and format in test_dispatch_streaming --------- Co-authored-by: Jaixii <algorithmictradingsolutions@gmail.com>
1 parent 30aab53 commit 8a05221

5 files changed

Lines changed: 202 additions & 19 deletions

File tree

‎.github/workflows/cowork-auto-pr.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
# without this step every run failed with "not a git repository" and no
1717
# PR was ever opened (fleet-wide defect: 11/11 seeded copies lacked it).
1818
- name: Check out the pushed branch
19-
uses: actions/checkout@v4
19+
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
2020
with:
2121
ref: ${{ github.ref_name }}
2222
fetch-depth: 0

‎src/devforge/cli.py‎

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -208,16 +208,13 @@ def dispatch(ctx: typer.Context):
208208
# `--config file.yaml`) reach the underlying CLI instead of being
209209
# rejected by typer as "No such option".
210210
forwarded = list(ctx.args)
211-
result = subprocess.run(
211+
# Stream output in real time via Popen with inherited file descriptors.
212+
# The previous subprocess.run(capture_output=True) buffered all output
213+
# in memory, causing UX lag and potential OOM on large tool output.
214+
proc = subprocess.Popen(
212215
[sys.executable, "-m", module_name] + forwarded,
213-
capture_output=True,
214-
text=True,
215216
)
216-
if result.stdout:
217-
sys.stdout.write(result.stdout)
218-
if result.stderr:
219-
sys.stderr.write(result.stderr)
220-
sys.exit(result.returncode)
217+
sys.exit(proc.wait())
221218

222219
dispatch.__name__ = tool_name
223220
dispatch.__doc__ = f"Run `{pkg}` commands via the {tool_name} subcommand."

‎tests/test_ci_hygiene.py‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""CI hygiene regression tests.
2+
3+
Ensures workflow files follow security best practices:
4+
- All GitHub Actions are SHA-pinned (no mutable tags like @v4)
5+
- No silent-failure traps (|| true on validation steps)
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import pytest
11+
import re
12+
from pathlib import Path
13+
14+
REPO_ROOT = Path(__file__).resolve().parent.parent
15+
WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows"
16+
17+
# Pattern: uses: OWNER/ACTION@REF
18+
# SHA-pinned refs are exactly 40 hex chars.
19+
# Mutable tags look like @v4, @v4.2.2, @main, @release/v1, etc.
20+
USES_PATTERN = re.compile(r"uses:\s*([^@\s]+)@(\S+)")
21+
SHA_PATTERN = re.compile(r"^[0-9a-f]{40}$")
22+
23+
# Local/composite actions (e.g. ./.github/actions/foo) don't need SHA pins.
24+
LOCAL_ACTION_PREFIX = "./"
25+
26+
27+
class TestWorkflowHygiene:
28+
"""Regression guards for CI workflow security and correctness."""
29+
30+
@pytest.fixture
31+
def workflow_files(self) -> list[Path]:
32+
files = list(WORKFLOWS_DIR.glob("*.yml")) + list(WORKFLOWS_DIR.glob("*.yaml"))
33+
if not files:
34+
pytest.skip("No workflow files found")
35+
return files
36+
37+
def test_all_actions_sha_pinned(self, workflow_files: list[Path]) -> None:
38+
"""Every remote action reference must use a 40-char SHA, not a mutable tag.
39+
40+
Mutable tags like @v4 can be silently moved to point at different commits,
41+
creating a supply-chain attack vector. SHA pins lock the exact commit.
42+
"""
43+
violations: list[str] = []
44+
for wf in workflow_files:
45+
for lineno, line in enumerate(wf.read_text(encoding="utf-8").splitlines(), 1):
46+
match = USES_PATTERN.search(line)
47+
if not match:
48+
continue
49+
action, ref = match.group(1), match.group(2)
50+
# Strip inline comments (e.g. "# v4.2.2")
51+
ref = ref.split("#")[0].strip()
52+
if action.startswith(LOCAL_ACTION_PREFIX):
53+
continue
54+
if not SHA_PATTERN.match(ref):
55+
violations.append(f"{wf.name}:{lineno} {action}@{ref}")
56+
57+
assert not violations, (
58+
f"Found {len(violations)} mutable action reference(s). "
59+
"Pin to a 40-char SHA instead:\n" + "\n".join(violations)
60+
)
61+
62+
def test_no_silent_failure_on_validation_steps(self, workflow_files: list[Path]) -> None:
63+
"""Validation/lint/test steps must not suppress failures with '|| true'.
64+
65+
A step whose purpose is to fail the build on defects (linters, type
66+
checkers, security scanners) must not hide failures. This catches the
67+
'validation theater' trap where a real check is neutered.
68+
"""
69+
validation_keywords = ("lint", "check", "test", "audit", "scan", "format", "typecheck")
70+
violations: list[str] = []
71+
for wf in workflow_files:
72+
lines = wf.read_text(encoding="utf-8").splitlines()
73+
for lineno, line in enumerate(lines, 1):
74+
stripped = line.strip()
75+
if "|| true" not in stripped:
76+
continue
77+
# Check if this line or the step name above contains a validation keyword
78+
context = " ".join(lines[max(0, lineno - 5) : lineno]).lower()
79+
if any(kw in context for kw in validation_keywords):
80+
violations.append(f"{wf.name}:{lineno} {stripped[:80]}")
81+
82+
assert not violations, (
83+
f"Found {len(violations)} validation step(s) with '|| true' suppression. "
84+
"Remove the suppression so failures are visible:\n" + "\n".join(violations)
85+
)

‎tests/test_cli.py‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -141,31 +141,35 @@ def test_dispatch_not_installed_shows_install_hint(self, _mock):
141141
assert 'pip install "git+https://github.com/Coding-Dev-Tools/devforge-cli.git[guard]"' in result.stdout
142142

143143
@mock.patch("devforge.cli._is_tool_installed", return_value=True)
144-
@mock.patch("devforge.cli.subprocess.run")
145-
def test_dispatch_installed_tool_runs(self, mock_run, _mock_installed):
144+
@mock.patch("devforge.cli.subprocess.Popen")
145+
def test_dispatch_installed_tool_runs(self, mock_popen, _mock_installed):
146146
"""When a tool is installed, dispatch calls the subprocess."""
147-
mock_run.return_value = mock.MagicMock(returncode=0)
147+
mock_proc = mock.MagicMock()
148+
mock_proc.wait.return_value = 0
149+
mock_popen.return_value = mock_proc
148150
with mock.patch("devforge.cli.sys.exit"):
149151
runner.invoke(app, ["guard"])
150-
mock_run.assert_called_once()
151-
cmd = mock_run.call_args[0][0]
152+
mock_popen.assert_called_once()
153+
cmd = mock_popen.call_args[0][0]
152154
assert "api_contract_guardian" in cmd
153155

154156
@mock.patch("devforge.cli._is_tool_installed", return_value=True)
155-
@mock.patch("devforge.cli.subprocess.run")
156-
def test_dispatch_forwards_tool_flags(self, mock_run, _mock_installed):
157+
@mock.patch("devforge.cli.subprocess.Popen")
158+
def test_dispatch_forwards_tool_flags(self, mock_popen, _mock_installed):
157159
"""Tool flags (e.g. `--config file.yaml`) must reach the underlying CLI.
158160
159161
Regression guard for the silent-failure trap where typer rejected any
160162
argument beginning with `-` as 'No such option' before the tool ran.
161163
With ignore_unknown_options/allow_extra_args, such flags are forwarded
162164
via ctx.args.
163165
"""
164-
mock_run.return_value = mock.MagicMock(returncode=0)
166+
mock_proc = mock.MagicMock()
167+
mock_proc.wait.return_value = 0
168+
mock_popen.return_value = mock_proc
165169
with mock.patch("devforge.cli.sys.exit"):
166170
runner.invoke(app, ["guard", "--config", "x.yaml", "--verbose"])
167-
mock_run.assert_called_once()
168-
cmd = mock_run.call_args[0][0]
171+
mock_popen.assert_called_once()
172+
cmd = mock_popen.call_args[0][0]
169173
# Underlying module is launched...
170174
assert "api_contract_guardian" in cmd
171175
# ...and the tool flags are forwarded, not swallowed by typer.

‎tests/test_dispatch_streaming.py‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""Regression tests for subprocess output streaming in dispatch.
2+
3+
The dispatch command must stream stdout/stderr in real time rather than
4+
buffering the entire output via capture_output=True. Long-running tools
5+
(deploydiff, schemaforge, configdrift on large datasets) can produce
6+
megabytes of output that should reach the user's terminal immediately.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import subprocess
12+
from devforge.cli import app
13+
from typer.testing import CliRunner
14+
from unittest import mock
15+
16+
runner = CliRunner()
17+
18+
19+
class TestDispatchStreaming:
20+
"""dispatch must NOT use subprocess.run with capture_output=True.
21+
22+
Real-time streaming requires subprocess.Popen (or subprocess.run with
23+
stdout=None, stderr=None) so the child process inherits the parent's
24+
file descriptors directly.
25+
"""
26+
27+
@mock.patch("devforge.cli._is_tool_installed", return_value=True)
28+
def test_dispatch_does_not_buffer_output(self, _mock_installed):
29+
"""subprocess.run must NOT be called with capture_output=True.
30+
31+
capture_output=True buffers the entire child output in memory before
32+
the parent can write anything. For tools that produce large or
33+
long-running output, this is a UX regression: the user sees nothing
34+
until the tool finishes, and memory usage grows unbounded.
35+
"""
36+
with mock.patch("devforge.cli.subprocess.run") as mock_run:
37+
mock_run.return_value = mock.MagicMock(returncode=0, stdout="", stderr="")
38+
with mock.patch("devforge.cli.sys.exit"):
39+
runner.invoke(app, ["guard", "--help"])
40+
41+
if mock_run.called:
42+
# If subprocess.run is used, it must NOT capture output
43+
call_kwargs = mock_run.call_args[1] if mock_run.call_args[1] else {}
44+
assert call_kwargs.get("capture_output") is not True, (
45+
"dispatch uses subprocess.run(capture_output=True) which buffers "
46+
"all output. Use subprocess.Popen or stdout=None to stream."
47+
)
48+
assert call_kwargs.get("stdout") is not subprocess.PIPE, (
49+
"dispatch uses stdout=PIPE which buffers output. Use stdout=None to inherit the parent's stdout."
50+
)
51+
52+
@mock.patch("devforge.cli._is_tool_installed", return_value=True)
53+
def test_dispatch_uses_popen_or_inherited_fds(self, _mock_installed):
54+
"""dispatch should use subprocess.Popen for real-time streaming,
55+
or subprocess.run without capture (stdout=None, stderr=None).
56+
"""
57+
with (
58+
mock.patch("devforge.cli.subprocess.Popen") as mock_popen,
59+
mock.patch("devforge.cli.subprocess.run") as mock_run,
60+
):
61+
# Set up Popen mock to simulate a successful run
62+
mock_proc = mock.MagicMock()
63+
mock_proc.wait.return_value = 0
64+
mock_popen.return_value = mock_proc
65+
66+
with mock.patch("devforge.cli.sys.exit"):
67+
runner.invoke(app, ["guard"])
68+
69+
# Either Popen was used (preferred for streaming)
70+
# or subprocess.run was used WITHOUT capture_output
71+
if mock_popen.called:
72+
# Good: Popen streams by default
73+
assert True
74+
elif mock_run.called:
75+
kwargs = mock_run.call_args[1] if mock_run.call_args[1] else {}
76+
assert kwargs.get("capture_output") is not True
77+
assert kwargs.get("stdout") is not subprocess.PIPE
78+
else:
79+
raise AssertionError("Neither subprocess.Popen nor subprocess.run was called")
80+
81+
@mock.patch("devforge.cli._is_tool_installed", return_value=True)
82+
def test_dispatch_exit_code_propagates(self, _mock_installed):
83+
"""The child process exit code must propagate to the parent."""
84+
with mock.patch("devforge.cli.subprocess.Popen") as mock_popen:
85+
mock_proc = mock.MagicMock()
86+
mock_proc.wait.return_value = 42
87+
mock_popen.return_value = mock_proc
88+
89+
with mock.patch("devforge.cli.sys.exit") as mock_exit:
90+
runner.invoke(app, ["guard"])
91+
92+
if mock_popen.called:
93+
# sys.exit(42) raises SystemExit; CliRunner catches it and
94+
# may call sys.exit(0) afterward. Check that 42 was among
95+
# the calls rather than asserting exactly one call.
96+
exit_codes = [c.args[0] for c in mock_exit.call_args_list]
97+
assert 42 in exit_codes, f"Expected exit code 42 in {exit_codes}"

0 commit comments

Comments
 (0)