Files
roboco/tests/unit/services/test_release_executor.py
T
60f571bc02 fix(release): publish via GitHub REST — gh CLI is not installed in any image (#331)
ReleaseExecutor.publish_release shelled out to 'gh release create', but no
Dockerfile installs the gh CLI (verified missing in the live orchestrator
container), so an armed release manager died at publish AFTER the release
commit was pushed. Publish now POSTs /repos/{owner}/{repo}/releases with the
project's decrypted token — same auth/httpx pattern as PR creation, same
fail-closed semantics (non-201 -> structured publish_failed, CEO retries;
the 300s deadline is the httpx client timeout). Subprocess publish-timeout
test replaced with REST-path tests (201/non-201/transport-error/no-token).

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-08 06:45:53 +02:00

536 lines
19 KiB
Python

"""ReleaseExecutor: fail-closed bump → gate → commit → CI → publish (post-approval).
The executor's correctness is its ORDERING + fail-closed aborts: a red gate
aborts before any commit, a red release-commit CI aborts before publish, and a
green path publishes exactly once. Tested against a fake ops that records the
call sequence; the production git/gh ops is exercised live (CEO-gated).
"""
from __future__ import annotations
import base64
from types import SimpleNamespace
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
import pytest
from roboco.config import settings
from roboco.services import release_executor as re
from roboco.services.release_executor import (
ReleaseExecutor,
ReleaseResult,
_GitReleaseOps,
_ReleaseContext,
_resolve_release_ci_workflow,
)
from roboco.services.release_readiness import ReleaseReadinessReport
if TYPE_CHECKING:
from pathlib import Path
_PLAN = ["pyproject.toml", "roboco/__init__.py", "CHANGELOG.md"]
_VERSION = "0.13.0"
_ONE = 1
def _report() -> ReleaseReadinessReport:
return ReleaseReadinessReport(
proposed_version=_VERSION,
bump_kind="minor",
change_summary=["feat: a thing"],
drafted_changelog=(
f"## [{_VERSION}] - 2026-06-25\n\n### Added\n- a thing (#1)\n"
),
version_bump_plan=list(_PLAN),
gaps=[],
migration_notes=[],
gate_state="green",
)
class _FakeOps:
"""Records the call sequence; flags drive gate/CI/already-published outcomes."""
def __init__(
self,
*,
already: bool = False,
gate: bool = True,
ci: bool = True,
commit_raises: str | None = None,
publish_raises: str | None = None,
):
self._already = already
self._gate = gate
self._ci = ci
self._commit_raises = commit_raises
self._publish_raises = publish_raises
# Half-landed (publish_failed retry) detection: a prior
# ``chore(release): {version}`` commit already on the branch. Set on the
# instance (not via __init__ — keeps the constructor under the arg-count
# gate) by tests that exercise the retry path.
self._existing_sha: str | None = None
self.calls: list[str] = []
self.bumped_plan: list[str] | None = None
self.bumped_version: str | None = None
self.halflanded_check = False
async def is_already_published(self, _version: str) -> bool:
self.calls.append("check")
return self._already
async def release_commit_sha(self, _version: str) -> str | None:
# Half-landed detection: a prior `chore(release): {version}` commit
# already on the branch means a publish_failed retry must NOT re-run the
# bump→changelog→gate→commit pipeline. Recorded via a flag (not calls)
# so the green-path call-sequence assertion is unaffected.
self.halflanded_check = True
return self._existing_sha
async def apply_version_bumps(self, plan: list[str], new_version: str) -> list[str]:
self.calls.append("bump")
self.bumped_plan = list(plan)
self.bumped_version = new_version
return list(plan)
async def write_changelog_entry(self, _entry: str) -> None:
self.calls.append("changelog")
async def run_gate(self) -> bool:
self.calls.append("gate")
return self._gate
async def commit_and_push(self, _version: str) -> str:
self.calls.append("commit")
if self._commit_raises is not None:
raise RuntimeError(self._commit_raises)
return "deadbeef"
async def wait_for_ci(self, _commit_sha: str) -> bool:
self.calls.append("ci")
return self._ci
async def publish_release(self, version: str, _notes: str) -> str:
self.calls.append("publish")
if self._publish_raises is not None:
raise RuntimeError(self._publish_raises)
return f"https://github.com/x/roboco/releases/tag/v{version}"
@pytest.mark.asyncio
async def test_green_path_publishes_once() -> None:
ops = _FakeOps()
result = await ReleaseExecutor(ops).execute(_report())
assert result.status == "published"
assert result.release_url is not None
assert result.commit_sha == "deadbeef"
assert ops.calls.count("publish") == _ONE
assert ops.calls == [
"check",
"bump",
"changelog",
"gate",
"commit",
"ci",
"publish",
]
@pytest.mark.asyncio
async def test_bump_targets_the_canonical_set() -> None:
ops = _FakeOps()
result = await ReleaseExecutor(ops).execute(_report())
assert ops.bumped_plan == _PLAN
assert ops.bumped_version == _VERSION
assert result.files_changed == _PLAN
@pytest.mark.asyncio
async def test_red_gate_aborts_before_commit() -> None:
ops = _FakeOps(gate=False)
result = await ReleaseExecutor(ops).execute(_report())
assert result.status == "gate_failed"
assert "commit" not in ops.calls
assert "publish" not in ops.calls
@pytest.mark.asyncio
async def test_red_ci_aborts_before_publish() -> None:
ops = _FakeOps(ci=False)
result = await ReleaseExecutor(ops).execute(_report())
assert result.status == "ci_failed"
assert "commit" in ops.calls
assert "publish" not in ops.calls
@pytest.mark.asyncio
async def test_already_published_is_a_noop() -> None:
ops = _FakeOps(already=True)
result = await ReleaseExecutor(ops).execute(_report())
assert result.status == "already_published"
assert "bump" not in ops.calls
assert "commit" not in ops.calls
assert "publish" not in ops.calls
@pytest.mark.asyncio
async def test_commit_push_failure_returns_structured_commit_failed() -> None:
"""#88: a RuntimeError from commit_and_push (gpgsign/pre-commit/non-ff
push) becomes a structured ``commit_failed`` result — not a 500 bubbling
out of ``approve``. Fail-closed: publish never runs."""
ops = _FakeOps(commit_raises="release push failed: non-fast-forward")
result = await ReleaseExecutor(ops).execute(_report())
assert result.status == "commit_failed"
assert result.commit_sha is None
assert result.release_url is None
assert "commit_failed" in result.detail or "push failed" in result.detail
assert "publish" not in ops.calls
assert "ci" not in ops.calls
@pytest.mark.asyncio
async def test_publish_failure_returns_structured_publish_failed() -> None:
"""#88: a RuntimeError from the GitHub release POST (auth/quota/network) becomes
a structured ``publish_failed`` result. The commit is already pushed and CI
is green, so the release is half-landed — the CEO can retry the publish
create`` for the same version (the executor is idempotent on the commit
side). No 500."""
ops = _FakeOps(publish_raises="release publish failed: HTTP 403: forbidden")
result = await ReleaseExecutor(ops).execute(_report())
assert result.status == "publish_failed"
assert result.commit_sha == "deadbeef"
assert result.release_url is None
assert "release publish failed" in result.detail
assert ops.calls.count("publish") == _ONE
def test_release_result_carries_outcome_fields() -> None:
result = ReleaseResult(
status="published",
version=_VERSION,
files_changed=list(_PLAN),
commit_sha="abc",
release_url="https://example/releases/v0.13.0",
detail="ok",
)
assert result.version == _VERSION
assert result.files_changed == _PLAN
assert result.release_url is not None
@pytest.mark.asyncio
async def test_half_landed_retry_skips_bump_and_republishes_only() -> None:
"""#87: a publish_failed retry (commit pushed + CI green, no tag yet) must
NOT re-run bump/changelog/gate/commit — that would re-insert the changelog
entry above the already-present ``## [X.Y.Z]`` heading (duplicate) and land a
second ``chore(release): X.Y.Z`` commit. The executor detects the
half-landed state via ``release_commit_sha`` (a prior release commit already
on the branch) and jumps straight to wait_for_ci + publish."""
ops = _FakeOps()
ops._existing_sha = "existingbeef"
result = await ReleaseExecutor(ops).execute(_report())
assert result.status == "published"
assert result.commit_sha == "existingbeef"
assert result.release_url is not None
assert ops.halflanded_check is True
assert "bump" not in ops.calls
assert "changelog" not in ops.calls
assert "gate" not in ops.calls
assert "commit" not in ops.calls
assert ops.calls == ["check", "ci", "publish"]
@pytest.mark.asyncio
async def test_wait_for_ci_scoped_to_release_commit_not_branch_latest(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""#318: a later commit landing on master during the ~40min wait must not
mask the release commit's green CI. ``wait_for_ci`` scopes the GitHub query
to the release commit_sha (``head_sha=``), so the branch-latest run (a later
sha) can't make the gate poll forever and false-fail as ci_failed."""
commit_sha = "release_commit_abc"
later_sha = "later_landed_def"
async def _fake_get_ci(_slug: str, **_kwargs: object) -> dict[str, str]:
# Mimic GitHub's head_sha filter: a run for the release sha only when
# asked for it (head_sha=commit_sha); the branch-latest (later commit)
# run otherwise. The release gate MUST scope to commit_sha to see green.
if _kwargs.get("head_sha") == commit_sha:
return {
"head_sha": commit_sha,
"conclusion": "success",
"run_url": "u",
"run_name": "n",
"branch": "master",
"completed_at": "t",
}
return {
"head_sha": later_sha,
"conclusion": "success",
"run_url": "u2",
"run_name": "n2",
"branch": "master",
"completed_at": "t2",
}
monkeypatch.setattr(
"roboco.services.git.get_git_service",
lambda _session: SimpleNamespace(get_latest_ci_conclusion=_fake_get_ci),
)
monkeypatch.setattr(re, "_CI_MAX_POLLS", 2)
async def _no_sleep(_secs: float) -> None:
return None
monkeypatch.setattr(re.asyncio, "sleep", _no_sleep)
ctx = _ReleaseContext(
slug="roboco-api",
default_branch="master",
root=tmp_path,
git_url="x",
git_prefix=[],
ci_workflow="ci.yml",
)
ops = _GitReleaseOps(session=MagicMock(), ctx=ctx)
ok = await ops.wait_for_ci(commit_sha)
assert ok is True
@pytest.mark.asyncio
async def test_wait_for_ci_polls_through_rerun(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A completed non-success conclusion on the release sha must not abort the
poll — a failed first attempt while a GitHub re-run is still in_progress
(excluded from the status=completed filter) can still flip the same
head_sha to success. Only ``conclusion == "success"`` returns True; loop
exhaustion returns False."""
commit_sha = "release_commit_abc"
seq = ["failure", "failure", "success"]
expected_polls = len(seq)
calls = {"n": 0}
async def _fake_get_ci(_slug: str, **kwargs: object) -> dict[str, object]:
i = min(calls["n"], len(seq) - 1)
calls["n"] += 1
return {
"head_sha": kwargs.get("head_sha", commit_sha),
"conclusion": seq[i],
"run_url": "u",
"run_name": "n",
"branch": "master",
"completed_at": "t",
}
monkeypatch.setattr(
"roboco.services.git.get_git_service",
lambda _session: SimpleNamespace(get_latest_ci_conclusion=_fake_get_ci),
)
monkeypatch.setattr(re, "_CI_MAX_POLLS", 5)
async def _no_sleep(_secs: float) -> None:
return None
monkeypatch.setattr(re.asyncio, "sleep", _no_sleep)
ctx = _ReleaseContext(
slug="roboco-api",
default_branch="master",
root=tmp_path,
git_url="x",
git_prefix=[],
ci_workflow="ci.yml",
)
ops = _GitReleaseOps(session=MagicMock(), ctx=ctx)
ok = await ops.wait_for_ci(commit_sha)
assert ok is True
assert calls["n"] == expected_polls
@pytest.mark.asyncio
async def test_wait_for_ci_exhausts_window_on_persistent_failure(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A definitive failure that never re-runs waits the full window then
returns False — keeps polling, never early-returns on non-success."""
commit_sha = "release_commit_abc"
max_polls = 3
calls = {"n": 0}
async def _fake_get_ci(_slug: str, **kwargs: object) -> dict[str, object]:
calls["n"] += 1
return {
"head_sha": kwargs.get("head_sha", commit_sha),
"conclusion": "failure",
"run_url": "u",
"run_name": "n",
"branch": "master",
"completed_at": "t",
}
monkeypatch.setattr(
"roboco.services.git.get_git_service",
lambda _session: SimpleNamespace(get_latest_ci_conclusion=_fake_get_ci),
)
monkeypatch.setattr(re, "_CI_MAX_POLLS", max_polls)
async def _no_sleep(_secs: float) -> None:
return None
monkeypatch.setattr(re.asyncio, "sleep", _no_sleep)
ctx = _ReleaseContext(
slug="roboco-api",
default_branch="master",
root=tmp_path,
git_url="x",
git_prefix=[],
ci_workflow="ci.yml",
)
ops = _GitReleaseOps(session=MagicMock(), ctx=ctx)
ok = await ops.wait_for_ci(commit_sha)
assert ok is False
assert calls["n"] == max_polls
def test_release_ci_workflow_decoupled_from_self_heal_setting(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""#402: the release CI gate must not inherit ``self_heal_ci_workflow``'s
empty-string tuning (documented valid for single-workflow repos), which would
degrade the fail-closed gate to the all-workflows mode git.py itself flags as
unreliable. The release gate always resolves a named workflow (default
``ci.yml``), never None."""
# The dangerous tuning an operator might apply for self-heal on a
# single-workflow repo — must NOT leak into the release gate.
monkeypatch.setattr(settings, "self_heal_ci_workflow", "")
monkeypatch.setattr(settings, "release_ci_workflow", "ci.yml")
assert _resolve_release_ci_workflow() == "ci.yml"
monkeypatch.setattr(settings, "release_ci_workflow", "release.yml")
assert _resolve_release_ci_workflow() == "release.yml"
# An empty release setting never falls through to None — always the default.
monkeypatch.setattr(settings, "release_ci_workflow", "")
assert _resolve_release_ci_workflow() == "ci.yml"
# --------------------------------------------------------------------------- #
# H11: the PAT must never appear in a git subprocess argv. The release clone
# and the release push carry the token via ``-c http.extraheader=Authorization:
# Basic <base64(x-access-token:TOKEN)>`` and a bare URL — never URL-embedded.
# --------------------------------------------------------------------------- #
def _basic_auth(token: str) -> str:
return base64.b64encode(f"x-access-token:{token}".encode()).decode()
class _DoneProc:
"""A subprocess that completes immediately with a fixed rc + stdout."""
def __init__(self, out: bytes = b"", returncode: int = 0) -> None:
self.returncode = returncode
self._out = out
async def communicate(self) -> tuple[bytes, bytes]:
return (self._out, b"")
def kill(self) -> None:
return None
async def wait(self) -> int:
return self.returncode
@pytest.mark.asyncio
async def test_release_clone_argv_uses_extraheader_not_url_token(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""H11: the release-clone argv carries the PAT via ``-c http.extraheader``,
never URL-embedded (``/proc/<pid>/cmdline`` would expose a URL token)."""
token = "ghp_SECRETCLONE"
git_url = "https://github.com/org/roboco.git"
expected_basic = _basic_auth(token)
git_prefix = ["-c", f"http.extraheader=Authorization: Basic {expected_basic}"]
captured: list[list[str]] = []
async def _exec(*args: str, **_kwargs: object) -> _DoneProc:
captured.append(list(args))
return _DoneProc()
monkeypatch.setattr(re.asyncio, "create_subprocess_exec", _exec)
monkeypatch.setattr(settings, "workspaces_root", str(tmp_path))
await re._prepare_release_clone("roboco-api", git_url, git_prefix, "master")
clone_argv = next(a for a in captured if "clone" in a)
assert f"https://{token}@" not in " ".join(clone_argv), (
f"raw token leaked into clone argv URL: {clone_argv}"
)
assert token not in clone_argv, f"raw token in clone argv: {clone_argv}"
assert git_url in clone_argv, f"bare git_url missing from clone argv: {clone_argv}"
assert "-c" in clone_argv
c_idx = clone_argv.index("-c")
assert (
clone_argv[c_idx + 1]
== f"http.extraheader=Authorization: Basic {expected_basic}"
)
assert "clone" in clone_argv[c_idx + 2 :]
@pytest.mark.asyncio
async def test_release_push_argv_uses_extraheader_not_url_token(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""H11: the release push argv carries the PAT via ``-c http.extraheader``
and pushes to the bare URL — never ``https://TOKEN@host/...``."""
token = "ghp_SECRETPUSH"
git_url = "https://github.com/org/roboco.git"
expected_basic = _basic_auth(token)
git_prefix = ["-c", f"http.extraheader=Authorization: Basic {expected_basic}"]
captured: list[list[str]] = []
# commit_and_push issues: add -A, commit -S -m, rev-parse HEAD, push.
responses = iter(
[
_DoneProc(b""), # add -A
_DoneProc(b""), # commit
_DoneProc(b"deadbeef\n"), # rev-parse HEAD
_DoneProc(b"ok"), # push
]
)
async def _exec(*args: str, **_kwargs: object) -> _DoneProc:
captured.append(list(args))
return next(responses)
monkeypatch.setattr(re.asyncio, "create_subprocess_exec", _exec)
ctx = _ReleaseContext(
slug="roboco-api",
default_branch="master",
root=tmp_path,
git_url=git_url,
git_prefix=git_prefix,
ci_workflow=None,
)
ops = _GitReleaseOps(session=MagicMock(), ctx=ctx)
sha = await ops.commit_and_push("0.13.0")
assert sha == "deadbeef"
push_argv = next(a for a in captured if "push" in a)
assert f"https://{token}@" not in " ".join(push_argv), (
f"raw token leaked into push argv URL: {push_argv}"
)
assert token not in push_argv, f"raw token in push argv: {push_argv}"
assert git_url in push_argv, f"bare git_url missing from push argv: {push_argv}"
assert "-c" in push_argv
c_idx = push_argv.index("-c")
assert (
push_argv[c_idx + 1]
== f"http.extraheader=Authorization: Basic {expected_basic}"
)
assert "push" in push_argv[c_idx + 2 :]