mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(release): gate on the head rung's CI verdict, not an in-container test run
make quality inside the production orchestrator container fails on ~1000 clean-env assumptions (armed compose flags, live Redis, host mounts) for a tree that is green in CI — proven live on the first org-proposed release. The execute-time gate now re-verifies the head rung's CI conclusion, fail-closed on absent or red with branch, sha, and conclusion in the failure detail; the pushed release commit keeps its own CI wait before publish.
This commit is contained in:
@@ -24,6 +24,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
### Changed
|
||||
|
||||
- **The release gate verifies CI instead of re-running the suite in production.** The executor's pre-commit gate ran `make quality` inside the orchestrator container, where production env (armed flags, host mounts, live Redis) breaks clean-env test assumptions wholesale — 1000 spurious failures on a tree that was green in CI. The gate now re-verifies the head rung's CI verdict at execute time, fail-closed on an absent or red run with the branch, sha, and conclusion named in the failure detail; the pushed release commit still gets its own CI wait before publish.
|
||||
- **Slave carries CI, and the release pipeline prefers curated notes.** The dev branch joined the CI push triggers — the release gate is fail-closed on the head commit's verdict, and a branch with no runs read as "unknown", silently blocking every release proposal. The readiness drafter now uses the curated `[Unreleased]` body as the release entry when one exists (falling back to per-commit transcription), and the executor empties `[Unreleased]` when stamping the entry so curated content never ships twice. CLAUDE.md gains the docs-sync engine paragraph it was missing.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -61,7 +61,7 @@ class ReleaseOps(Protocol):
|
||||
|
||||
async def promote_env_chain(self) -> None: ...
|
||||
|
||||
async def run_gate(self) -> bool: ...
|
||||
async def run_gate(self) -> tuple[bool, str]: ...
|
||||
|
||||
async def commit_and_push(self, version: str) -> str: ...
|
||||
|
||||
@@ -183,14 +183,15 @@ class ReleaseExecutor:
|
||||
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():
|
||||
gate_ok, gate_detail = await self._ops.run_gate()
|
||||
if not gate_ok:
|
||||
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).",
|
||||
detail=(f"release gate refused — {gate_detail} (fail-closed).")[:280],
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -285,6 +286,7 @@ class _GitReleaseOps:
|
||||
self._root = ctx.root
|
||||
self._git_url = ctx.git_url
|
||||
self._git_prefix = ctx.git_prefix
|
||||
self._head_branch = ctx.env_chain[0] if ctx.env_chain else ctx.prod_branch
|
||||
self._ci_workflow = ctx.ci_workflow
|
||||
self._env_chain = ctx.env_chain
|
||||
|
||||
@@ -385,21 +387,31 @@ class _GitReleaseOps:
|
||||
f"env-chain merge {branch}→prod failed: {out.strip()[:200]}"
|
||||
)
|
||||
|
||||
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)
|
||||
async def run_gate(self) -> tuple[bool, str]:
|
||||
"""Re-verify the head rung's CI verdict at execute time.
|
||||
|
||||
The readiness sweep already required a green run on the head rung to
|
||||
propose at all; re-checking the same signal here keeps the gate
|
||||
fail-closed without re-running the suite inside the production
|
||||
container, whose env (armed flags, host mounts, live Redis) breaks
|
||||
clean-env test assumptions wholesale. The pushed release commit still
|
||||
gets its own ``wait_for_ci`` before publish.
|
||||
"""
|
||||
rc, sha_out = await self._git("rev-parse", f"origin/{self._head_branch}")
|
||||
if rc != 0:
|
||||
logger.warning(
|
||||
"release gate (make quality) failed or timed out",
|
||||
tail=out[-2000:],
|
||||
)
|
||||
return rc == 0
|
||||
return False, f"cannot resolve origin/{self._head_branch}"
|
||||
head_sha = sha_out.strip()
|
||||
from roboco.services.git import get_git_service
|
||||
|
||||
git = get_git_service(self._session)
|
||||
ci = await git.get_latest_ci_conclusion(
|
||||
self._slug, workflow=self._ci_workflow, head_sha=head_sha
|
||||
)
|
||||
conclusion = ((ci or {}).get("conclusion") or "absent").lower()
|
||||
detail = f"CI on {self._head_branch}@{head_sha[:8]} is {conclusion}"
|
||||
if conclusion != "success":
|
||||
logger.warning("release gate refused", detail=detail)
|
||||
return conclusion == "success", detail
|
||||
|
||||
async def commit_and_push(self, version: str) -> str:
|
||||
add_rc, add_out = await self._git("add", "-A")
|
||||
|
||||
@@ -104,9 +104,9 @@ class _FakeOps:
|
||||
async def write_changelog_entry(self, _entry: str) -> None:
|
||||
self.calls.append("changelog")
|
||||
|
||||
async def run_gate(self) -> bool:
|
||||
async def run_gate(self) -> tuple[bool, str]:
|
||||
self.calls.append("gate")
|
||||
return self._gate
|
||||
return self._gate, "CI on slave@deadbeef is failure"
|
||||
|
||||
async def commit_and_push(self, _version: str) -> str:
|
||||
self.calls.append("commit")
|
||||
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -105,6 +106,7 @@ def _ops() -> _GitReleaseOps:
|
||||
ops._git_url = "https://github.com/o/roboco"
|
||||
ops._git_prefix = []
|
||||
ops._ci_workflow = None
|
||||
ops._head_branch = "slave"
|
||||
# publish_release resolves the token via a (monkeypatched) ProjectService;
|
||||
# the session itself is never touched in these tests.
|
||||
ops._session = cast("AsyncSession", None)
|
||||
@@ -189,23 +191,23 @@ async def test_git_op_timeout_reaps_the_zombie(monkeypatch: pytest.MonkeyPatch)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_gate_times_out_returns_false(
|
||||
async def test_run_gate_fails_closed_on_absent_ci(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A hung ``make quality`` fails closed: ``run_gate`` returns False (the
|
||||
release aborts before commit), and the child is killed — not wedged."""
|
||||
monkeypatch.setattr(
|
||||
"roboco.services.release_executor._RELEASE_GATE_TIMEOUT_SECONDS", 0.05
|
||||
)
|
||||
proc = _HangingProc()
|
||||
"""No CI verdict on the head rung reads as absent — the gate refuses
|
||||
(fail-closed) with a detail naming the branch and sha."""
|
||||
proc = _DoneProc(0, b"deadbeefcafe\n")
|
||||
monkeypatch.setattr(
|
||||
"roboco.services.release_executor.asyncio.create_subprocess_exec",
|
||||
_exec_returning(proc),
|
||||
)
|
||||
fake_git = MagicMock()
|
||||
fake_git.get_latest_ci_conclusion = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr("roboco.services.git.get_git_service", lambda _s: fake_git)
|
||||
ops = _ops()
|
||||
passed = await asyncio.wait_for(ops.run_gate(), timeout=2.0)
|
||||
passed, detail = await asyncio.wait_for(ops.run_gate(), timeout=2.0)
|
||||
assert passed is False
|
||||
assert proc.killed
|
||||
assert "absent" in detail and "deadbeef" in detail
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
@@ -359,17 +361,21 @@ async def test_git_op_green_path_returns_real_rc(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_gate_green_path_returns_true(
|
||||
async def test_run_gate_green_ci_returns_true(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A green ``make quality`` (rc 0) returns True — the timeout wrap doesn't
|
||||
turn a passing gate into a failure."""
|
||||
proc = _DoneProc(0, b"all good\n")
|
||||
"""A green CI verdict on the head rung passes the gate."""
|
||||
proc = _DoneProc(0, b"deadbeefcafe\n")
|
||||
monkeypatch.setattr(
|
||||
"roboco.services.release_executor.asyncio.create_subprocess_exec",
|
||||
_exec_returning(proc),
|
||||
)
|
||||
fake_git = MagicMock()
|
||||
fake_git.get_latest_ci_conclusion = AsyncMock(
|
||||
return_value={"conclusion": "success", "head_sha": "deadbeefcafe"}
|
||||
)
|
||||
monkeypatch.setattr("roboco.services.git.get_git_service", lambda _s: fake_git)
|
||||
ops = _ops()
|
||||
passed = await asyncio.wait_for(ops.run_gate(), timeout=2.0)
|
||||
passed, detail = await asyncio.wait_for(ops.run_gate(), timeout=2.0)
|
||||
assert passed is True
|
||||
assert not proc.killed
|
||||
assert "success" in detail
|
||||
|
||||
Reference in New Issue
Block a user