Root-cause fix for main erosion (phantom merge): code of ORCH-067/069 reached `done` while absent from origin/main (only their auto docs-PRs landed). - FR-1: verify_merged_to_main confirms merge ONLY by `git merge-base --is-ancestor <validated_sha> origin/main`; the OR-branch pr_already_merged is removed (a merged PR no longer confirms). Empty SHA / git error -> False. - FR-2: pr_already_merged demoted to merge_pr idempotency-guard; counts a PR only when merged & head.ref==<branch> & base.ref=="main" (explicit in-loop filter). - FR-3: merge_pr selects the open code-PR by head==<branch> AND base==main. - FR-5: new deterministic check_main_regression in _handle_merge_verify (after confirmed SHA-in-main, before done) verifies MAIN_REGRESSION_MARKERS still in origin/main; deterministic count==0 -> alert "main regressed" + HOLD (NOT done, no rollback); git error of the grep -> fail-open. Kill-switch ORCH_REGRESSION_GUARD_ENABLED; non-self -> no-op. - FR-4: root .gitattributes `CHANGELOG.md merge=union` so Unreleased edits auto-merge on rebase without conflict (branch not rolled back). Invariants unchanged (STAGE_TRANSITIONS, QG_CHECKS, deploy-status, merge-gate, image-freshness, DB schema, external HTTP API); non-self repos no-op (INV-5); never-raise (INV-1); merge only via Gitea PR-API (INV-2). Docs: CHANGELOG, .env.example (README/ADR updated by architect). Tests: tests/test_orch073_*.py (TC-01..18); existing merge-gate tests updated for the new code-PR filter. Refs: ORCH-073 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
115 lines
4.9 KiB
Python
115 lines
4.9 KiB
Python
"""ORCH-073 FR-5 — main-integrity regression guard wired into _handle_merge_verify.
|
|
|
|
Covers TC-13..16 / AC-3 / AC-5 / AC-6 / INV-1. Calls the under-gate handler directly
|
|
with mocked merge_gate primitives + side effects (Plane/Telegram). Asserts the
|
|
return contract: False == advance to `done`, True == HOLD (alert, NOT done).
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
|
|
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
|
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
|
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch073_rg.db"))
|
|
|
|
from unittest.mock import MagicMock # noqa: E402
|
|
|
|
import pytest # noqa: E402
|
|
|
|
from src import stage_engine, image_freshness # noqa: E402
|
|
from src.stage_engine import AdvanceResult, _handle_merge_verify # noqa: E402
|
|
|
|
REPO = "orchestrator"
|
|
WI = "ORCH-073"
|
|
BRANCH = "feature/ORCH-073-x"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _wire(monkeypatch):
|
|
# Under-gate is in scope for the self-hosting repo; guard enabled.
|
|
monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: True)
|
|
monkeypatch.setattr(stage_engine.settings, "regression_guard_enabled", True)
|
|
monkeypatch.setattr(image_freshness, "validated_revision", lambda r, b: "deadbeef")
|
|
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "merged PR #1"))
|
|
# Silence Plane/Telegram side effects (assert on .called where relevant).
|
|
for name in ("set_issue_blocked", "plane_add_comment", "send_telegram", "link_for"):
|
|
monkeypatch.setattr(stage_engine, name, MagicMock())
|
|
monkeypatch.setattr(
|
|
stage_engine.self_deploy, "record_merged_to_main", MagicMock(return_value=True)
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-13 (AC-6): SHA in main AND markers intact -> advance (return False), no alert.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc13_confirmed_and_intact_advances(monkeypatch):
|
|
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
|
|
monkeypatch.setattr(stage_engine.merge_gate, "check_main_regression", lambda r, b: (True, "markers intact (4)"))
|
|
|
|
res = AdvanceResult()
|
|
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
|
|
|
assert intervened is False # advance to done
|
|
assert res.alerted is False
|
|
assert not stage_engine.set_issue_blocked.called
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-14 (AC-3): SHA NOT in main (docs-only merge) -> HOLD + alert + Blocked.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc14_sha_not_in_main_holds(monkeypatch):
|
|
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: False)
|
|
# Guard must never even run when SHA is not confirmed.
|
|
monkeypatch.setattr(
|
|
stage_engine.merge_gate, "check_main_regression",
|
|
lambda r, b: (_ for _ in ()).throw(AssertionError("guard must not run when not confirmed")),
|
|
)
|
|
|
|
res = AdvanceResult()
|
|
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
|
|
|
assert intervened is True # HOLD
|
|
assert res.advanced is False
|
|
assert res.note == "merge-not-verified-hold"
|
|
assert stage_engine.set_issue_blocked.called
|
|
assert stage_engine.send_telegram.called
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-15 (AC-5): SHA in main BUT a marker missing -> HOLD + 'main regressed' alert.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc15_marker_missing_holds(monkeypatch):
|
|
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
|
|
monkeypatch.setattr(
|
|
stage_engine.merge_gate, "check_main_regression",
|
|
lambda r, b: (False, "main regressed: ORCH-067 code missing (plane_issue_link @ src/notifications.py)"),
|
|
)
|
|
|
|
res = AdvanceResult()
|
|
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
|
|
|
assert intervened is True # HOLD, NOT done
|
|
assert res.advanced is False
|
|
assert res.note == "main-regressed-hold"
|
|
assert stage_engine.set_issue_blocked.called
|
|
assert stage_engine.send_telegram.called
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-16 (INV-1): an internal verifier error -> HOLD + alert, no exception escapes.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc16_internal_error_holds_never_raises(monkeypatch):
|
|
def boom(r, b, s):
|
|
raise RuntimeError("verifier exploded")
|
|
|
|
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", boom)
|
|
|
|
res = AdvanceResult()
|
|
# Must NOT raise.
|
|
intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res)
|
|
|
|
assert intervened is True # HOLD
|
|
assert res.advanced is False
|
|
assert res.alerted is True
|
|
assert "merge-verify-error" in (res.note or "")
|