Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
d82d4b0
Add release-to-pypi-uv composite action
leynos Sep 18, 2025
e5893bd
Document trusted publishing permissions
leynos Sep 18, 2025
f78dee3
Clarify GH_TOKEN handling in README
leynos Sep 18, 2025
6198517
Fix local usage example
leynos Sep 18, 2025
c4f1411
Improve GitHub release error handling
leynos Sep 18, 2025
2f412c9
Fix cmd_utils discovery in publish script
leynos Sep 18, 2025
1fe7eeb
Skip more build caches
leynos Sep 18, 2025
6fa4f97
Fail fast on TOML parse errors
leynos Sep 18, 2025
1bd1e2a
Format release summary output
leynos Sep 18, 2025
423757a
Add no-tag error test
leynos Sep 18, 2025
b837bb7
Test TOML parse failures
leynos Sep 18, 2025
7c0ee91
Make uv python version configurable
leynos Sep 18, 2025
32078b5
Document concurrency guard in usage
leynos Sep 18, 2025
9f3443f
Address review feedback with retries and tests
leynos Sep 18, 2025
bf50fd3
Improve TOML version validation and test strategy
leynos Sep 19, 2025
acc4d3e
Add cmd-mox users guide
leynos Sep 19, 2025
9a3db7a
Use cmd-mox to stub rust build command tests (#92)
leynos Sep 20, 2025
effe1f4
Mark Windows smoke tests as xfail (#94)
leynos Sep 20, 2025
3db6752
Address reviewer feedback for release-to-pypi-uv action (#98)
leynos Sep 21, 2025
75a83c1
Fix cmd_mox fixture usage and expose dev extra (#101)
leynos Sep 21, 2025
aabef78
Add missing docstrings to release-to-pypi-uv modules (#103)
leynos Sep 21, 2025
eb48dec
Silence type-check import lints for release action (#104)
leynos Sep 21, 2025
551b453
Narrow module fixtures to ModuleType (#105)
leynos Sep 21, 2025
33bbd80
Add docstrings for release-to-pypi-uv tests and helpers (#106)
leynos Sep 21, 2025
c5ccda9
Scope type-checking imports (#111)
leynos Sep 21, 2025
1368491
Test full success message for matching versions (#113)
leynos Sep 21, 2025
1d90f22
Address review comments (#116)
leynos Sep 22, 2025
0d66408
Address review feedback for release-to-pypi-uv actions (#112)
leynos Sep 22, 2025
074ed0d
Update ci.yml remove unneeded step
leynos Sep 22, 2025
e673de7
Fix formatting
leynos Sep 22, 2025
da97bc3
Fix Windows xfail marker removal for pytest 8 (#120)
leynos Sep 22, 2025
286abde
Handle runtime probe timeouts and expand release tests (#119)
leynos Sep 22, 2025
47c2f88
Expand TOML skip directories for release validation (#122)
leynos Sep 23, 2025
d27e6b7
Reinforce regression coverage for release tooling (#130)
leynos Sep 23, 2025
1805b4a
Harden release-to-pypi-uv workflow and regression coverage (#134)
leynos Sep 23, 2025
a32b824
Rebase python-lib-release-action onto origin/main; resolve conflicts …
leynos Sep 23, 2025
c44e013
Close cargo pipes when coverage stream missing (#137)
leynos Sep 23, 2025
dd7f7ab
Handle release script auth failure and multiline outputs (#136)
leynos Sep 23, 2025
2372583
Fix cross install warning expectation (#139)
leynos Sep 24, 2025
ed0eb1a
Update fmt target description (#140)
leynos Sep 24, 2025
fb721b7
Resolve rebase conflicts and align runtime tests with platform/timeou…
leynos Sep 24, 2025
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
209 changes: 114 additions & 95 deletions .github/actions/generate-coverage/scripts/run_rust.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,14 @@ def get_line_coverage_percent_from_cobertura(xml_file: Path) -> str:
return _format_percent(covered, total)


def _safe_close_text_stream(stream: typ.TextIO | None) -> None:
"""Close ``stream`` while suppressing any cleanup errors."""
if stream is None:
return
with contextlib.suppress(Exception):
stream.close()


def _run_cargo(args: list[str]) -> str:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting the platform-specific output pumping logic into a helper function to make _run_cargo flatter and more focused.

Consider extracting the two big “pump” branches (win vs posix) into a helper so that _run_cargo becomes flat and focused on orchestration (spawn → pump → wait → cleanup). For example:

def _pump_cargo_output(proc: subprocess.Popen[str]) -> list[str]:
    """Pump proc.stdout/stderr to console, return collected stdout lines."""
    stdout_lines: list[str] = []

    if os.name == "nt":
        thread_excs: list[Exception] = []
        def _win_pump(src: TextIO, out: TextIO, collect: bool):
            try:
                for line in iter(src.readline, ""):
                    out.write(line); out.flush()
                    if collect:
                        stdout_lines.append(line.rstrip("\r\n"))
            except Exception as ex:
                thread_excs.append(ex)

        threads = [
            threading.Thread(target=_win_pump, args=(proc.stdout, sys.stdout, True), daemon=True),
            threading.Thread(target=_win_pump, args=(proc.stderr, sys.stderr, False), daemon=True),
        ]
        for t in threads: t.start()
        for t in threads: t.join()
        if thread_excs:
            proc.kill(); proc.wait()
            raise thread_excs[0]

    else:
        sel = selectors.DefaultSelector()
        sel.register(proc.stdout, selectors.EVENT_READ, "out")
        sel.register(proc.stderr, selectors.EVENT_READ, "err")
        try:
            while sel.get_map():
                for key, _ in sel.select():
                    line = key.fileobj.readline()
                    if not line:
                        sel.unregister(key.fileobj)
                        continue
                    if key.data == "out":
                        typer.echo(line, nl=False)
                        stdout_lines.append(line.rstrip("\r\n"))
                    else:
                        typer.echo(line, err=True, nl=False)
        finally:
            sel.close()

    return stdout_lines

Then shrink _run_cargo to:

def _run_cargo(args: list[str]) -> str:
    typer.echo(f"$ cargo {shlex.join(args)}")
    proc = cargo[args].popen(...)

    # early stream‐check + cleanup omitted for brevity

    try:
        stdout_lines = _pump_cargo_output(proc)
        ret = proc.wait()
        if ret != 0:
            typer.echo(f"cargo ... failed with code {ret}", err=True)
            raise typer.Exit(code=ret or 1)
        return "\n".join(stdout_lines)
    finally:
        _safe_close_text_stream(proc.stdout)
        _safe_close_text_stream(proc.stderr)

This removes two nested if/else layers inside _run_cargo, collapses common cleanup, and keeps all behavior identical.

"""Run ``cargo`` with ``args`` streaming output and return ``stdout``."""
typer.echo(f"$ cargo {shlex.join(args)}")
Expand All @@ -160,104 +168,115 @@ def _run_cargo(args: list[str]) -> str:
encoding="utf-8",
errors="replace",
)
if proc.stdout is None or proc.stderr is None:
msg = "cargo output streams not captured"
raise RuntimeError(msg)
stdout_lines: list[str] = []

if os.name == "nt":
thread_exceptions: list[Exception] = []

def pump(src: typ.TextIO, *, to_stdout: bool) -> None:
dest = sys.stdout if to_stdout else sys.stderr
try:
for line in iter(src.readline, ""):
dest.write(line)
dest.flush()
if to_stdout:
stdout_lines.append(line.rstrip("\r\n"))
except Exception as exc: # noqa: BLE001
thread_exceptions.append(exc)
if os.environ.get("RUN_RUST_DEBUG") == "1" or os.environ.get(
"DEBUG_UTF8"
):
sys.stderr.write(f"Exception in pump thread: {exc}\n")
sys.stderr.write(traceback.format_exc())

threads = [
threading.Thread(
name="cargo-stdout",
target=pump,
args=(proc.stdout,),
kwargs={"to_stdout": True},
daemon=True,
),
threading.Thread(
name="cargo-stderr",
target=pump,
args=(proc.stderr,),
kwargs={"to_stdout": False},
daemon=True,
),
]
for thread in threads:
thread.start()
# Kill cargo promptly if a pump fails to avoid deadlocks on the other pipe.
while True:
try:
if proc.stdout is None or proc.stderr is None:
missing_streams = []
if proc.stdout is None:
missing_streams.append("stdout")
if proc.stderr is None:
missing_streams.append("stderr")
missing = ", ".join(missing_streams)
message = f"cargo output streams not captured: missing {missing}"
with contextlib.suppress(Exception):
proc.kill()
with contextlib.suppress(Exception):
proc.wait(timeout=5)
_safe_close_text_stream(proc.stdout)
_safe_close_text_stream(proc.stderr)
typer.echo(f"::error::{message}", err=True)
raise typer.Exit(1)
stdout_lines: list[str] = []

if os.name == "nt":
thread_exceptions: list[Exception] = []

def pump(src: typ.TextIO, *, to_stdout: bool) -> None:
dest = sys.stdout if to_stdout else sys.stderr
try:
for line in iter(src.readline, ""):
dest.write(line)
dest.flush()
if to_stdout:
stdout_lines.append(line.rstrip("\r\n"))
except Exception as exc: # noqa: BLE001
thread_exceptions.append(exc)
if os.environ.get("RUN_RUST_DEBUG") == "1" or os.environ.get(
"DEBUG_UTF8"
):
sys.stderr.write(f"Exception in pump thread: {exc}\n")
sys.stderr.write(traceback.format_exc())

threads = [
threading.Thread(
name="cargo-stdout",
target=pump,
args=(proc.stdout,),
kwargs={"to_stdout": True},
daemon=True,
),
threading.Thread(
name="cargo-stderr",
target=pump,
args=(proc.stderr,),
kwargs={"to_stdout": False},
daemon=True,
),
]
for thread in threads:
thread.start()
# Kill cargo promptly if a pump fails to avoid deadlocks on the other pipe.
while True:
if thread_exceptions:
with contextlib.suppress(Exception):
proc.kill()
break
if not any(t.is_alive() for t in threads):
break
for t in threads:
t.join(timeout=0.1)
# Ensure all threads have finished before handling results.
for thread in threads:
thread.join()
if thread_exceptions:
proc.wait()
raise thread_exceptions[0]
else:
sel = selectors.DefaultSelector()
try:
sel.register(proc.stdout, selectors.EVENT_READ, data="stdout")
sel.register(proc.stderr, selectors.EVENT_READ, data="stderr")

while sel.get_map():
for key, _ in sel.select():
line = key.fileobj.readline()
if not line:
sel.unregister(key.fileobj)
continue
if key.data == "stdout":
typer.echo(line, nl=False)
stdout_lines.append(line.rstrip("\r\n"))
else:
typer.echo(line, err=True, nl=False)
except Exception:
# Ensure cargo does not outlive the parent if the selector loop fails.
with contextlib.suppress(Exception):
proc.kill()
break
if not any(t.is_alive() for t in threads):
break
for t in threads:
t.join(timeout=0.1)
# Ensure all threads have finished before closing streams.
for thread in threads:
thread.join()
# Streams are guaranteed non-None by earlier guard.
proc.stdout.close()
proc.stderr.close()
if thread_exceptions:
proc.wait()
raise thread_exceptions[0]
else:
sel = selectors.DefaultSelector()
try:
sel.register(proc.stdout, selectors.EVENT_READ, data="stdout")
sel.register(proc.stderr, selectors.EVENT_READ, data="stderr")

while sel.get_map():
for key, _ in sel.select():
line = key.fileobj.readline()
if not line:
sel.unregister(key.fileobj)
continue
if key.data == "stdout":
typer.echo(line, nl=False)
stdout_lines.append(line.rstrip("\r\n"))
else:
typer.echo(line, err=True, nl=False)
except Exception:
# Ensure cargo does not outlive the parent if the selector loop fails.
with contextlib.suppress(Exception):
proc.kill()
proc.wait()
raise
finally:
sel.close()
# Safe due to earlier guard.
proc.stdout.close()
proc.stderr.close()

retcode = proc.wait()
if retcode != 0:
typer.echo(
f"cargo {shlex.join(args)} failed with code {retcode}",
err=True,
)
raise typer.Exit(code=retcode or 1)
return "\n".join(stdout_lines)
proc.wait()
raise
finally:
sel.close()

retcode = proc.wait()
if retcode != 0:
typer.echo(
f"cargo {shlex.join(args)} failed with code {retcode}",
err=True,
)
raise typer.Exit(code=retcode or 1)
return "\n".join(stdout_lines)
finally:
_safe_close_text_stream(proc.stdout)
_safe_close_text_stream(proc.stderr)


def _merge_lcov(base: Path, extra: Path) -> None:
Expand Down
95 changes: 84 additions & 11 deletions .github/actions/generate-coverage/tests/test_scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ def kill(self) -> None:
if track_lifecycle:
self.killed = True

def wait(self) -> int:
def wait(self, timeout: float | None = None) -> int:
if track_lifecycle:
self.waited = True
return returncode
Expand Down Expand Up @@ -198,6 +198,37 @@ def fake_echo(line: str, *, err: bool = False, nl: bool = True) -> None:
assert res == "out-line"


def test_run_cargo_windows_closes_streams(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``_run_cargo`` closes captured streams on success."""
mod = _load_module(monkeypatch, "run_rust")
monkeypatch.setattr(mod.os, "name", "nt")
monkeypatch.setattr(mod.typer, "echo", lambda *a, **k: None)

class TrackingStream(io.StringIO):
def __init__(self, value: str) -> None:
super().__init__(value)
self.close_calls = 0

def close(self) -> None:
self.close_calls += 1
super().close()

stdout = TrackingStream("out-line\n")
stderr = TrackingStream("err-line\n")
fake_cargo = _make_fake_cargo(stdout, stderr)
monkeypatch.setattr(mod, "cargo", fake_cargo)

result = mod._run_cargo(["llvm-cov"])

assert result == "out-line"
assert stdout.closed
assert stderr.closed
assert stdout.close_calls >= 1
assert stderr.close_calls >= 1


def test_run_cargo_windows_nonzero_exit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
Expand Down Expand Up @@ -228,8 +259,8 @@ def test_run_cargo_windows_pump_exception(

class BoomIO(io.StringIO):
def readline(self) -> str:
msg = "boom in pump"
raise RuntimeError(msg)
message = "boom in pump"
raise RuntimeError(message)

fake_cargo = _make_fake_cargo(BoomIO(), io.StringIO(""), track_lifecycle=True)
monkeypatch.setattr(mod, "cargo", fake_cargo)
Expand All @@ -249,9 +280,14 @@ def test_run_cargo_windows_none_stdout(
monkeypatch.setattr(mod.os, "name", "nt")
monkeypatch.setattr(mod.typer, "echo", lambda *a, **k: None)

monkeypatch.setattr(mod, "cargo", _make_fake_cargo(None, "err-line\n"))
with pytest.raises(RuntimeError):
fake_cargo = _make_fake_cargo(None, "err-line\n")
monkeypatch.setattr(mod, "cargo", fake_cargo)
with pytest.raises(mod.typer.Exit):
mod._run_cargo([])
proc = fake_cargo.last_proc
assert proc is not None
assert proc.stderr is not None
assert proc.stderr.closed


def test_run_cargo_windows_none_stderr(
Expand All @@ -262,9 +298,46 @@ def test_run_cargo_windows_none_stderr(
monkeypatch.setattr(mod.os, "name", "nt")
monkeypatch.setattr(mod.typer, "echo", lambda *a, **k: None)

monkeypatch.setattr(mod, "cargo", _make_fake_cargo("out-line\n", None))
with pytest.raises(RuntimeError):
fake_cargo = _make_fake_cargo("out-line\n", None)
monkeypatch.setattr(mod, "cargo", fake_cargo)
with pytest.raises(mod.typer.Exit):
mod._run_cargo([])
proc = fake_cargo.last_proc
assert proc is not None
assert proc.stdout is not None
assert proc.stdout.closed


def test_run_cargo_stream_close_error_suppressed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Errors closing streams are suppressed during cleanup."""
mod = _load_module(monkeypatch, "run_rust")
monkeypatch.setattr(mod.os, "name", "nt")
monkeypatch.setattr(mod.typer, "echo", lambda *a, **k: None)

class ExplodingStream(io.StringIO):
def __init__(self, value: str) -> None:
super().__init__(value)
self.close_calls = 0

def close(self) -> None:
self.close_calls += 1
super().close()
message = "close failure"
raise RuntimeError(message)

stdout = ExplodingStream("out-line\n")
stderr = io.StringIO("err-line\n")
fake_cargo = _make_fake_cargo(stdout, stderr)
monkeypatch.setattr(mod, "cargo", fake_cargo)

result = mod._run_cargo(["llvm-cov"])

assert result == "out-line"
assert stdout.close_calls >= 1
assert stdout.closed
assert stderr.closed


def test_run_rust_with_cucumber(tmp_path: Path, shell_stubs: StubManager) -> None:
Expand Down Expand Up @@ -510,8 +583,8 @@ def test_lcov_permission_error(
lcov.write_text("LF:1\nLH:1\n")

def bad_read_text(*_: object, **__: object) -> str:
msg = "nope"
raise PermissionError(msg)
message = "nope"
raise PermissionError(message)

monkeypatch.setattr(Path, "read_text", bad_read_text, raising=False)
with pytest.raises(run_rust_module.typer.Exit) as excinfo:
Expand Down Expand Up @@ -679,8 +752,8 @@ def test_cobertura_permission_error(
xml.write_text("<coverage/>")

def raise_permission_error(*_: object, **__: object) -> object:
msg = "denied"
raise PermissionError(msg)
message = "denied"
raise PermissionError(message)

import coverage_parsers

Expand Down
22 changes: 22 additions & 0 deletions .github/actions/release-to-pypi-uv/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Changelog

## v1.0.2 (2025-09-18)

- Add a configurable `python-version` input and ensure all uv commands honour
it, letting workflows pin their interpreter version.
- Harden release validation: retry GitHub API lookups with exponential
backoff, tighten semantic version detection, and expand TOML validation
coverage along with unit tests for the helper scripts.

## v1.0.1 (2025-09-18)

- Document required workflow permissions for trusted publishing, clarify that
the action forwards `GITHUB_TOKEN` automatically, and fix the README usage
example to reference the local path without a version suffix.

## v1.0.0 (2025-09-18)

- Initial release: resolve release tags, ensure GitHub Release readiness, and
publish Python distributions with uv Trusted Publishing support.
- Validate `pyproject.toml` versions against the release tag and optionally
block dynamic version declarations.
Loading
Loading