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
+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