fix(merge-gate): SHA-in-main as sole merge-verify criterion + main regression guard

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>
This commit is contained in:
2026-06-08 16:21:48 +03:00
committed by stream
parent fa9b96545c
commit aff334e82b
17 changed files with 887 additions and 47 deletions

View File

@@ -42,7 +42,7 @@ def test_tc07_merge_actor_calls_gitea_merge(monkeypatch):
def fake_get(url, params=None, headers=None, timeout=None):
get_calls.append((url, params))
return _Resp(200, [{"head": {"ref": branch}, "number": 7}])
return _Resp(200, [{"head": {"ref": branch}, "base": {"ref": "main"}, "number": 7}])
def fake_post(url, json=None, headers=None, timeout=None):
post_calls.append((url, json))
@@ -104,7 +104,7 @@ def test_tc09_never_raise_on_http_error(monkeypatch):
def test_tc09_merge_endpoint_non_2xx_is_false(monkeypatch):
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
monkeypatch.setattr(
httpx, "get", lambda *a, **k: _Resp(200, [{"head": {"ref": "feature/ORCH-071-x"}, "number": 3}])
httpx, "get", lambda *a, **k: _Resp(200, [{"head": {"ref": "feature/ORCH-071-x"}, "base": {"ref": "main"}, "number": 3}])
)
monkeypatch.setattr(httpx, "post", lambda *a, **k: _Resp(409, text="conflict"))
ok, msg = merge_gate.merge_pr("orchestrator", "feature/ORCH-071-x")
@@ -119,7 +119,7 @@ def test_tc09_merge_endpoint_non_2xx_is_false(monkeypatch):
def test_tc13_no_shell_out_no_force_push(monkeypatch):
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
monkeypatch.setattr(
httpx, "get", lambda *a, **k: _Resp(200, [{"head": {"ref": "feature/ORCH-071-x"}, "number": 9}])
httpx, "get", lambda *a, **k: _Resp(200, [{"head": {"ref": "feature/ORCH-071-x"}, "base": {"ref": "main"}, "number": 9}])
)
monkeypatch.setattr(httpx, "post", lambda *a, **k: _Resp(200))