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:
Renn F
2026-07-16 03:12:04 +02:00
parent bdb0dd6cdd
commit ce5e263b79
4 changed files with 53 additions and 34 deletions
+1
View File
@@ -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
+29 -17
View File
@@ -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")
+2 -2
View File
@@ -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