Files
roboco/roboco/services/release_executor.py
T
Renn F 68899ffda9 [F078] release_executor: deadline every subprocess (git/make/gh/clone)
A hung git/make/gh/clone would block the CEO-gated release loop
indefinitely. Wrap each proc.communicate() in asyncio.wait_for via a
shared _await_proc helper; on expiry proc.kill() the child and return a
non-zero rc (124) so every caller's fail-closed branch fires. Mirrors the
quality-gate _run_one kill-on-timeout idiom.

Deadlines are generous (30min gate / 10min clone / 5min push+gh) so a
legitimate slow op is never wrongly aborted — floor-assertion tests pin
the floors to guard exactly that logical regression. Green path returns
the real rc unchanged.
2026-06-28 17:56:03 +02:00

390 lines
15 KiB
Python

"""Release executor — prepares, gates, and publishes a release post-approval.
Runs ONLY after the CEO approves a proposal. It is fail-closed: a red
``make quality`` aborts before any commit, and a red release-commit CI aborts
before any publish. Idempotent: re-running an already-published version is a
no-op. The bump/gate/commit/publish steps live behind a ``ReleaseOps`` seam so
the fail-closed ORDERING (the correctness this feature exists to guarantee) is
unit-tested deterministically; the production ``_GitReleaseOps`` performs the
real git / ``make quality`` / ``gh`` work on a writable clone.
"""
from __future__ import annotations
import asyncio
import re
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Protocol
import structlog
logger = structlog.get_logger()
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.services.release_readiness import ReleaseReadinessReport
# CHANGELOG.md is in the canonical bump set but is NOT version-string-replaced —
# it receives a NEW entry. The bump skips it (and any path ending in it).
_CHANGELOG_NAME = "CHANGELOG.md"
@dataclass(frozen=True)
class ReleaseResult:
"""The outcome of an execution attempt — what happened and where it stopped."""
# published | gate_failed | ci_failed | already_published | no_change
status: str
version: str
files_changed: list[str]
commit_sha: str | None
release_url: str | None
detail: str
class ReleaseOps(Protocol):
"""The side-effecting steps of a release, injected so the executor is testable."""
async def is_already_published(self, version: str) -> bool: ...
async def apply_version_bumps(
self, plan: list[str], new_version: str
) -> list[str]: ...
async def write_changelog_entry(self, entry: str) -> None: ...
async def run_gate(self) -> bool: ...
async def commit_and_push(self, version: str) -> str: ...
async def wait_for_ci(self, commit_sha: str) -> bool: ...
async def publish_release(self, version: str, notes: str) -> str: ...
class ReleaseExecutor:
"""Orchestrate a fail-closed release from an approved readiness report."""
def __init__(self, ops: ReleaseOps) -> None:
self._ops = ops
async def execute(self, report: ReleaseReadinessReport) -> ReleaseResult:
"""Bump → gate → commit/push → CI → publish, aborting on any red step."""
version = report.proposed_version
if await self._ops.is_already_published(version):
return ReleaseResult(
status="already_published",
version=version,
files_changed=[],
commit_sha=None,
release_url=None,
detail=f"v{version} is already published; nothing to do.",
)
files = await self._ops.apply_version_bumps(report.version_bump_plan, version)
await self._ops.write_changelog_entry(report.drafted_changelog)
if not await self._ops.run_gate():
return ReleaseResult(
status="gate_failed",
version=version,
files_changed=files,
commit_sha=None,
release_url=None,
detail="make quality failed — aborted before commit (fail-closed).",
)
commit_sha = await self._ops.commit_and_push(version)
if not await self._ops.wait_for_ci(commit_sha):
return ReleaseResult(
status="ci_failed",
version=version,
files_changed=files,
commit_sha=commit_sha,
release_url=None,
detail="release-commit CI was not green — not published (fail-closed).",
)
release_url = await self._ops.publish_release(version, report.drafted_changelog)
logger.info(
"release published",
version=version,
commit=commit_sha,
url=release_url,
)
return ReleaseResult(
status="published",
version=version,
files_changed=files,
commit_sha=commit_sha,
release_url=release_url,
detail=f"Published v{version}.",
)
# --------------------------------------------------------------------------- #
# Production ops — real git / make / gh on a writable clone.
# --------------------------------------------------------------------------- #
_CI_POLL_INTERVAL_SECONDS = 30
_CI_MAX_POLLS = 80 # ~40 min ceiling
# Subprocess deadlines. A hung git / make / gh would otherwise block the
# CEO-gated release loop indefinitely. Each is generous enough that a
# legitimate, slow operation is never wrongly aborted — only a true hang fails
# closed. Mirrors the quality-gate ``_run_one`` kill-on-timeout idiom.
_GIT_OP_TIMEOUT_SECONDS = 300 # git add/commit/rev-parse/ls-remote/push
_RELEASE_GATE_TIMEOUT_SECONDS = 1800 # make quality — full ruff/mypy/pytest suite
_PUBLISH_TIMEOUT_SECONDS = 300 # gh release create
_CLONE_TIMEOUT_SECONDS = 600 # git clone / rm -rf the release clone
# The conventional non-zero rc a timed-out subprocess reports so every caller's
# fail-closed branch (rc != 0) fires instead of hanging the release loop.
_TIMEOUT_RC = 124
async def _await_proc(
proc: asyncio.subprocess.Process, timeout: float
) -> tuple[int, str]:
"""Communicate with a subprocess under a deadline; on expiry ``kill()`` the
child so a hang fails closed instead of wedging the release loop, and
return a non-zero rc so every caller's fail-closed branch fires."""
try:
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except TimeoutError:
proc.kill()
return _TIMEOUT_RC, f"subprocess timed out after {int(timeout)}s"
return proc.returncode or 0, out.decode("utf-8", "replace")
@dataclass(frozen=True)
class _ReleaseContext:
"""Writable-clone coordinates for the production release ops."""
slug: str
default_branch: str
root: Path
auth_url: str
ci_workflow: str | None
class _GitReleaseOps:
"""Real release steps on a writable clone of RoboCo (token-authenticated)."""
def __init__(self, session: AsyncSession, ctx: _ReleaseContext) -> None:
self._session = session
self._slug = ctx.slug
self._default_branch = ctx.default_branch
self._root = ctx.root
self._auth_url = ctx.auth_url
self._ci_workflow = ctx.ci_workflow
async def _git(self, *args: str) -> tuple[int, str]:
proc = await asyncio.create_subprocess_exec(
"git",
"-C",
str(self._root),
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
return await _await_proc(proc, _GIT_OP_TIMEOUT_SECONDS)
async def is_already_published(self, version: str) -> bool:
rc, out = await self._git("ls-remote", "--tags", "origin", f"v{version}")
return rc == 0 and bool(out.strip())
def _current_version(self) -> str:
text = (self._root / "pyproject.toml").read_text(encoding="utf-8")
match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
return match.group(1) if match else ""
async def apply_version_bumps(self, plan: list[str], new_version: str) -> list[str]:
"""Replace the current version with ``new_version`` across the plan.
CHANGELOG.md is skipped here (it gets a new entry, not a string-replace)
but is kept in the returned list since it IS changed by the entry. uv.lock
is bumped only within the ``roboco`` package block to avoid clobbering a
dependency that happens to share the version string.
"""
old = self._current_version()
for rel in plan:
if rel.endswith(_CHANGELOG_NAME):
continue
path = self._root / rel
if not path.is_file():
continue
text = path.read_text(encoding="utf-8")
if rel.endswith("uv.lock"):
text = _bump_uv_lock(text, old, new_version)
else:
text = text.replace(old, new_version)
path.write_text(text, encoding="utf-8")
return list(plan)
async def write_changelog_entry(self, entry: str) -> None:
path = self._root / _CHANGELOG_NAME
existing = path.read_text(encoding="utf-8") if path.is_file() else ""
path.write_text(_insert_changelog_entry(existing, entry), encoding="utf-8")
async def run_gate(self) -> bool:
proc = await asyncio.create_subprocess_exec(
"make",
"quality",
cwd=str(self._root),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
rc, out = await _await_proc(proc, _RELEASE_GATE_TIMEOUT_SECONDS)
if rc != 0:
logger.warning(
"release gate (make quality) failed or timed out",
tail=out[-2000:],
)
return rc == 0
async def commit_and_push(self, version: str) -> str:
add_rc, add_out = await self._git("add", "-A")
if add_rc != 0:
logger.error("release git add failed", error=add_out.strip()[:300])
raise RuntimeError(f"release git add failed: {add_out.strip()[:200]}")
commit_rc, commit_out = await self._git(
"commit", "-S", "-m", f"chore(release): {version}"
)
if commit_rc != 0:
# F012: a failed commit (gpgsign/pre-commit reject/no-op bump) must
# abort BEFORE rev-parse+push — otherwise the pre-bump base gets
# pushed and tagged as the new version.
logger.error("release commit failed", error=commit_out.strip()[:300])
raise RuntimeError(f"release commit failed: {commit_out.strip()[:200]}")
_, out = await self._git("rev-parse", "HEAD")
sha = out.strip()
push_rc, push_out = await self._git(
"push", self._auth_url, f"HEAD:{self._default_branch}"
)
if push_rc != 0:
logger.error("release push failed", error=push_out.strip()[:300])
raise RuntimeError(f"release push failed: {push_out.strip()[:200]}")
return sha
async def wait_for_ci(self, commit_sha: str) -> bool:
from roboco.services.git import get_git_service
git = get_git_service(self._session)
for _ in range(_CI_MAX_POLLS):
ci = await git.get_latest_ci_conclusion(
self._slug, workflow=self._ci_workflow
)
if ci and ci.get("head_sha") == commit_sha:
conclusion = (ci.get("conclusion") or "").lower()
if conclusion == "success":
return True
if conclusion:
logger.warning("release CI not green", conclusion=conclusion)
return False
await asyncio.sleep(_CI_POLL_INTERVAL_SECONDS)
logger.warning("release CI poll timed out", sha=commit_sha)
return False
async def publish_release(self, version: str, notes: str) -> str:
tag = f"v{version}"
proc = await asyncio.create_subprocess_exec(
"gh",
"release",
"create",
tag,
"--title",
tag,
"--notes",
notes,
"--target",
self._default_branch,
cwd=str(self._root),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
rc, out = await _await_proc(proc, _PUBLISH_TIMEOUT_SECONDS)
text = out.strip()
if rc != 0:
logger.error("gh release create failed", error=text[:300])
raise RuntimeError(f"gh release create failed: {text[:200]}")
url = next(
(line.strip() for line in text.splitlines() if line.startswith("http")),
"",
)
return url
def _bump_uv_lock(text: str, old: str, new: str) -> str:
"""Bump only the ``roboco`` package's version inside uv.lock."""
pattern = re.compile(
r'(\[\[package\]\]\nname = "roboco"\nversion = )"' + re.escape(old) + '"'
)
return pattern.sub(rf'\1"{new}"', text)
def _insert_changelog_entry(existing: str, entry: str) -> str:
"""Insert ``entry`` above the first released version heading (Keep a Changelog)."""
block = entry.rstrip() + "\n"
if not existing.strip():
return block
lines = existing.splitlines(keepends=True)
for idx, line in enumerate(lines):
if line.startswith("## [") and "Unreleased" not in line:
return "".join(lines[:idx]) + block + "\n" + "".join(lines[idx:])
return existing.rstrip() + "\n\n" + block
async def get_release_executor(session: AsyncSession) -> ReleaseExecutor:
"""Build a ReleaseExecutor with a production ops over a fresh writable clone."""
from roboco.config import settings
from roboco.services.project import get_project_service
from roboco.services.workspace import _inject_token_into_url
slug = (settings.self_heal_project_slug or "roboco-api").strip()
project_svc = get_project_service(session)
project = await project_svc.get_by_slug(slug)
if project is None:
raise RuntimeError(f"release executor: project {slug!r} not found")
token = await project_svc.get_decrypted_token_by_slug(slug)
git_url = str(project.git_url)
default_branch = str(project.default_branch or "master")
auth_url = _inject_token_into_url(git_url, token)
root = await _prepare_release_clone(slug, auth_url, default_branch)
ctx = _ReleaseContext(
slug=slug,
default_branch=default_branch,
root=root,
auth_url=auth_url,
ci_workflow=(settings.self_heal_ci_workflow or None),
)
return ReleaseExecutor(_GitReleaseOps(session, ctx))
async def _prepare_release_clone(slug: str, auth_url: str, default_branch: str) -> Path:
"""Fresh, writable, token-authenticated clone for the release commit + push."""
from roboco.config import settings
root = Path(settings.workspaces_root) / "_release" / slug
if root.exists():
await _run(["rm", "-rf", str(root)])
root.parent.mkdir(parents=True, exist_ok=True)
rc, out = await _run(
["git", "clone", "--branch", default_branch, auth_url, str(root)]
)
if rc != 0:
raise RuntimeError(f"release clone failed: {out.strip()[:200]}")
return root
async def _run(cmd: list[str]) -> tuple[int, str]:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
return await _await_proc(proc, _CLONE_TIMEOUT_SECONDS)