Files
roboco/tests/unit/services/test_release_executor.py
T
5612375cba Feat/v0.13.0 (#270)
* feat(release): add release-manager feature flag (default off)

* feat(release): change classification + semver-bump derivation

* feat(release): readiness audit (changelog/version-ref/docs/migration/gate)

* feat(release): release-manager engine proposes a gated release

* feat(release): fail-closed release executor (bump, gate, publish)

* feat(release): CEO approve/reject release-proposal surface

* docs(release): document the gated release manager

* feat(memory): add org-memory feature flags (default off)

* feat(memory): add playbooks table + status enum + migration

* feat(memory): playbook service with auditor curation transitions

* feat(memory): playbooks RAG index plugin

* feat(memory): index a playbook into RAG on approval

* feat(memory): distill a high-signal lesson at task completion

* feat(memory): keep private journal reflections out of the shared RAG corpus

* feat(memory): draft_playbook verb + auditor curation verbs

* fix(ci): resolve mypy tests/ errors blocking the gate (UUID casts, annotations)

* feat(memory): auto-inject similar lessons/playbooks into the briefing

* feat(memory): auditor playbook review queue (api + panel)

* docs(memory): document the org-memory loop + playbook verbs

* fix(provisioning): idempotent pitch provisioning (reuse product/project by slug on re-approval)

* fix(memory): add chunks_playbooks to the chunk schema + isolate release route tests

- Migration 030's CHUNK_TABLES was missing chunks_playbooks, breaking the
  IndexType<->migration parity guard once the PLAYBOOKS index landed. The
  upgrade is ALTER ... IF EXISTS so adding it is safe on any DB shape.
- The release-route fixture's approve/reject paths call db.commit() (real
  behavior), so a held proposal outlived the per-test rollback and leaked
  into engine tests that read the global list_open_release_proposals().
  Tear down source=release_manager rows after each test.
- Make the gather_snapshot real-repo smoke version-agnostic (semver match)
  so it stops pinning the literal repo version.

* chore(release): 0.13.0

* ++

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-26 01:43:08 +02:00

144 lines
4.4 KiB
Python

"""ReleaseExecutor: fail-closed bump → gate → commit → CI → publish (post-approval).
The executor's correctness is its ORDERING + fail-closed aborts: a red gate
aborts before any commit, a red release-commit CI aborts before publish, and a
green path publishes exactly once. Tested against a fake ops that records the
call sequence; the production git/gh ops is exercised live (CEO-gated).
"""
from __future__ import annotations
import pytest
from roboco.services.release_executor import ReleaseExecutor, ReleaseResult
from roboco.services.release_readiness import ReleaseReadinessReport
_PLAN = ["pyproject.toml", "roboco/__init__.py", "CHANGELOG.md"]
_VERSION = "0.13.0"
_ONE = 1
def _report() -> ReleaseReadinessReport:
return ReleaseReadinessReport(
proposed_version=_VERSION,
bump_kind="minor",
change_summary=["feat: a thing"],
drafted_changelog=(
f"## [{_VERSION}] - 2026-06-25\n\n### Added\n- a thing (#1)\n"
),
version_bump_plan=list(_PLAN),
gaps=[],
migration_notes=[],
gate_state="green",
)
class _FakeOps:
"""Records the call sequence; flags drive gate/CI/already-published outcomes."""
def __init__(self, *, already: bool = False, gate: bool = True, ci: bool = True):
self._already = already
self._gate = gate
self._ci = ci
self.calls: list[str] = []
self.bumped_plan: list[str] | None = None
self.bumped_version: str | None = None
async def is_already_published(self, _version: str) -> bool:
self.calls.append("check")
return self._already
async def apply_version_bumps(self, plan: list[str], new_version: str) -> list[str]:
self.calls.append("bump")
self.bumped_plan = list(plan)
self.bumped_version = new_version
return list(plan)
async def write_changelog_entry(self, _entry: str) -> None:
self.calls.append("changelog")
async def run_gate(self) -> bool:
self.calls.append("gate")
return self._gate
async def commit_and_push(self, _version: str) -> str:
self.calls.append("commit")
return "deadbeef"
async def wait_for_ci(self, _commit_sha: str) -> bool:
self.calls.append("ci")
return self._ci
async def publish_release(self, version: str, _notes: str) -> str:
self.calls.append("publish")
return f"https://github.com/x/roboco/releases/tag/v{version}"
@pytest.mark.asyncio
async def test_green_path_publishes_once() -> None:
ops = _FakeOps()
result = await ReleaseExecutor(ops).execute(_report())
assert result.status == "published"
assert result.release_url is not None
assert result.commit_sha == "deadbeef"
assert ops.calls.count("publish") == _ONE
assert ops.calls == [
"check",
"bump",
"changelog",
"gate",
"commit",
"ci",
"publish",
]
@pytest.mark.asyncio
async def test_bump_targets_the_canonical_set() -> None:
ops = _FakeOps()
result = await ReleaseExecutor(ops).execute(_report())
assert ops.bumped_plan == _PLAN
assert ops.bumped_version == _VERSION
assert result.files_changed == _PLAN
@pytest.mark.asyncio
async def test_red_gate_aborts_before_commit() -> None:
ops = _FakeOps(gate=False)
result = await ReleaseExecutor(ops).execute(_report())
assert result.status == "gate_failed"
assert "commit" not in ops.calls
assert "publish" not in ops.calls
@pytest.mark.asyncio
async def test_red_ci_aborts_before_publish() -> None:
ops = _FakeOps(ci=False)
result = await ReleaseExecutor(ops).execute(_report())
assert result.status == "ci_failed"
assert "commit" in ops.calls
assert "publish" not in ops.calls
@pytest.mark.asyncio
async def test_already_published_is_a_noop() -> None:
ops = _FakeOps(already=True)
result = await ReleaseExecutor(ops).execute(_report())
assert result.status == "already_published"
assert "bump" not in ops.calls
assert "commit" not in ops.calls
assert "publish" not in ops.calls
def test_release_result_carries_outcome_fields() -> None:
result = ReleaseResult(
status="published",
version=_VERSION,
files_changed=list(_PLAN),
commit_sha="abc",
release_url="https://example/releases/v0.13.0",
detail="ok",
)
assert result.version == _VERSION
assert result.files_changed == _PLAN
assert result.release_url is not None