mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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.
390 lines
15 KiB
Python
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)
|