diff --git a/.gitea/scripts/status-reaper.py b/.gitea/scripts/status-reaper.py index 74f9842b0..a7893cf3f 100644 --- a/.gitea/scripts/status-reaper.py +++ b/.gitea/scripts/status-reaper.py @@ -143,6 +143,11 @@ PR_SHADOW_COMPENSATION_DESCRIPTION = ( "shadowed by successful push status on same SHA; see " ".gitea/scripts/status-reaper.py)" ) +GOVERNANCE_SHADOW_COMPENSATION_DESCRIPTION = ( + "Compensated by status-reaper (non-required pull_request/pull_request_review " + "governance shadow overridden by successful pull_request_target status; see " + ".gitea/scripts/status-reaper.py)" +) CANCELLED_PUSH_COMPENSATION_DESCRIPTION = ( "Compensated by status-reaper (push run was cancelled/superseded; " "Gitea 1.22.6 reports cancelled runs as failure statuses)" @@ -153,6 +158,20 @@ CANCELLED_DESCRIPTION = "Has been cancelled" # default-branch workflow runs. PUSH_SUFFIX = " (push)" PULL_REQUEST_SUFFIX = " (pull_request)" +PULL_REQUEST_TARGET_SUFFIX = " (pull_request_target)" +PULL_REQUEST_REVIEW_SUFFIX = " (pull_request_review)" + +# Governance workflows whose non-required `(pull_request)` / `(pull_request_review)` +# shadows may be compensated when the trusted `(pull_request_target)` variant is +# green. This is an EXACT active allowlist — every other workflow is preserved, +# even if it has no `push:` trigger, to avoid masking real failures. +GOVERNANCE_SHADOW_ALLOWLIST = frozenset( + {"sop-checklist", "qa-review", "security-review"} +) +# Retired workflows whose historical shadow contexts still appear on old commits +# and must remain compensatable even though the workflow YAML has been removed. +# They are treated as known non-push when absent from the trigger map. +GOVERNANCE_SHADOW_RETIRED_ALLOWLIST = frozenset({"sop-tier-check"}) # -------------------------------------------------------------------------- # Conductor snapshot (operator-config#158) @@ -488,6 +507,51 @@ def push_equivalent_context(context: str) -> str | None: return f"{workflow_name} / {job_name}{PUSH_SUFFIX}" +def target_equivalent_context(context: str, source_suffix: str) -> str | None: + """Return the matching `(pull_request_target)` context for a suffixed context. + + Handles `(pull_request)` and `(pull_request_review)` governance shadows. + """ + parsed = parse_suffixed_context(context, source_suffix) + if parsed is None: + return None + workflow_name, job_name = parsed + return f"{workflow_name} / {job_name}{PULL_REQUEST_TARGET_SUFFIX}" + + +def is_governance_shadow_context( + context: str, workflow_trigger_map: dict[str, bool] +) -> bool: + """True if `context` is a compensatable governance shadow. + + Active governance workflows (`sop-checklist`, `qa-review`, `security-review`) + are compensatable only when their trigger map entry is explicitly `False`. + Retired workflows (`sop-tier-check`) may be absent from the trigger map + because their YAML was removed; they are treated as known non-push so their + historical shadow contexts remain compensatable. + + Workflows that DO have a `push:` trigger are excluded even if they are in an + allowlist — their PR/review status is an independent gate signal. Unknown + workflows or workflows not in any allowlist are preserved (fail-closed). + """ + for suffix in (PULL_REQUEST_SUFFIX, PULL_REQUEST_REVIEW_SUFFIX): + parsed = parse_suffixed_context(context, suffix) + if parsed is not None: + workflow_name, _job_name = parsed + if workflow_name in GOVERNANCE_SHADOW_RETIRED_ALLOWLIST: + # Retired workflow: absent from the trigger map is expected. + # Only a push-triggered retired workflow is preserved. + has_push = workflow_trigger_map.get(workflow_name) + return has_push is not True + if workflow_name not in GOVERNANCE_SHADOW_ALLOWLIST: + return False + # Active allowlist workflow: require an explicit known-no-push entry. + # If the parser ever misses the workflow, fail-closed (preserve). + has_push = workflow_trigger_map.get(workflow_name) + return has_push is False + return False + + # -------------------------------------------------------------------------- # Compensating POST # -------------------------------------------------------------------------- @@ -562,6 +626,8 @@ def reap( "compensated_pr_shadowed_by_push_success": 0, "compensated_cancelled_push": 0, "preserved_pr_without_push_success": 0, + "compensated_governance_shadow": 0, + "preserved_governance_without_target_success": 0, "compensated_contexts": [], } @@ -594,6 +660,36 @@ def reap( counters["preserved_non_failure"] += 1 continue + # Governance shadow compensation (#2770 / #2767). + # Non-required `(pull_request)` and `(pull_request_review)` contexts + # emitted by governance workflows (sop-checklist, qa-review, + # security-review, retired sop-tier-check) are informational shadows + # of the required `(pull_request_target)` context. When the trusted + # target context succeeded, the shadow must not keep the aggregate + # commit status red. CI workflows that also have a `push:` trigger are + # excluded — their `(pull_request)` status is an independent gate. + if is_governance_shadow_context(context, workflow_trigger_map): + source_suffix = ( + PULL_REQUEST_SUFFIX + if context.endswith(PULL_REQUEST_SUFFIX) + else PULL_REQUEST_REVIEW_SUFFIX + ) + target_equivalent = target_equivalent_context(context, source_suffix) + if target_equivalent is not None and target_equivalent in successful_contexts: + post_compensating_status( + sha, + context, + s.get("target_url"), + description=GOVERNANCE_SHADOW_COMPENSATION_DESCRIPTION, + dry_run=dry_run, + ) + counters["compensated"] += 1 + counters["compensated_governance_shadow"] += 1 + counters["compensated_contexts"].append(context) + else: + counters["preserved_governance_without_target_success"] += 1 + continue + # Default-branch `pull_request` contexts can be stale shadows of # the exact same workflow/job already proven by the successful # `push` context on the same SHA. Compensate only that narrow @@ -618,7 +714,7 @@ def reap( # Only `(push)`-suffix contexts hit the hardcoded-suffix bug. # Other failed contexts are preserved unless handled by the - # pull-request-shadow rule above. + # governance-shadow or pull-request-shadow rules above. if not context.endswith(PUSH_SUFFIX): counters["preserved_non_push_suffix"] += 1 continue @@ -766,6 +862,8 @@ def reap_branch( "compensated_pr_shadowed_by_push_success": 0, "compensated_cancelled_push": 0, "preserved_pr_without_push_success": 0, + "compensated_governance_shadow": 0, + "preserved_governance_without_target_success": 0, "compensated_per_sha": {}, "sha_api_errors": 0, "skipped": True, @@ -783,6 +881,8 @@ def reap_branch( "compensated_pr_shadowed_by_push_success": 0, "compensated_cancelled_push": 0, "preserved_pr_without_push_success": 0, + "compensated_governance_shadow": 0, + "preserved_governance_without_target_success": 0, "compensated_per_sha": {}, "sha_api_errors": 0, } @@ -825,6 +925,8 @@ def reap_branch( "compensated_pr_shadowed_by_push_success", "compensated_cancelled_push", "preserved_pr_without_push_success", + "compensated_governance_shadow", + "preserved_governance_without_target_success", ): aggregate[key] += per_sha[key] diff --git a/.gitea/scripts/tests/test_status_reaper_api.py b/.gitea/scripts/tests/test_status_reaper_api.py index e2d02aeb4..d2b917c84 100644 --- a/.gitea/scripts/tests/test_status_reaper_api.py +++ b/.gitea/scripts/tests/test_status_reaper_api.py @@ -1,6 +1,7 @@ import importlib.util import json import pathlib +import pytest import urllib.error @@ -250,3 +251,339 @@ def test_get_combined_status_self_fetches_when_sha_not_in_snapshot(monkeypatch): assert combined["state"] == "success" finally: os.unlink(path) + + +def test_reap_compensates_governance_shadow_when_target_passed(monkeypatch): + mod = load_reaper() + posted = [] + + def fake_post(sha, context, target_url, *, description="", dry_run=False): + posted.append((sha, context, target_url, description, dry_run)) + + monkeypatch.setattr(mod, "post_compensating_status", fake_post) + + # sop-checklist has no push trigger, so its failed (pull_request) shadow is + # noise when the required (pull_request_target) context is green. + counters = mod.reap( + {"sop-checklist": False, "qa-review": False, "security-review": False}, + { + "statuses": [ + { + "context": "sop-checklist / all-items-acked (pull_request)", + "status": "failure", + "target_url": "https://git.example.test/sop-pr", + }, + { + "context": "sop-checklist / all-items-acked (pull_request_target)", + "status": "success", + }, + { + "context": "qa-review / approved (pull_request_review)", + "status": "failure", + "target_url": "https://git.example.test/qa-pr-review", + }, + { + "context": "qa-review / approved (pull_request_target)", + "status": "success", + }, + ], + }, + "db3b7a93e31adc0cb072a6d177d92dd73275a191", + ) + + assert counters["compensated_governance_shadow"] == 2 + assert counters["preserved_governance_without_target_success"] == 0 + assert posted == [ + ( + "db3b7a93e31adc0cb072a6d177d92dd73275a191", + "sop-checklist / all-items-acked (pull_request)", + "https://git.example.test/sop-pr", + mod.GOVERNANCE_SHADOW_COMPENSATION_DESCRIPTION, + False, + ), + ( + "db3b7a93e31adc0cb072a6d177d92dd73275a191", + "qa-review / approved (pull_request_review)", + "https://git.example.test/qa-pr-review", + mod.GOVERNANCE_SHADOW_COMPENSATION_DESCRIPTION, + False, + ), + ] + + +def test_reap_preserves_governance_shadow_when_target_missing_or_failed(monkeypatch): + mod = load_reaper() + posted = [] + monkeypatch.setattr( + mod, + "post_compensating_status", + lambda sha, context, target_url, *, description="", dry_run=False: posted.append( + context + ), + ) + + counters = mod.reap( + {"sop-checklist": False}, + { + "statuses": [ + { + "context": "sop-checklist / all-items-acked (pull_request)", + "status": "failure", + }, + # target context failed → preserve the shadow as a real signal. + { + "context": "sop-checklist / all-items-acked (pull_request_target)", + "status": "failure", + }, + ], + }, + "db3b7a93e31adc0cb072a6d177d92dd73275a191", + ) + + assert counters["compensated_governance_shadow"] == 0 + assert counters["preserved_governance_without_target_success"] == 1 + assert posted == [] + + +def test_reap_preserves_ci_pull_request_failure_even_when_target_passed(monkeypatch): + mod = load_reaper() + posted = [] + monkeypatch.setattr( + mod, + "post_compensating_status", + lambda sha, context, target_url, *, description="", dry_run=False: posted.append( + context + ), + ) + + # A CI workflow that also has a push trigger is NOT a governance shadow; + # its (pull_request) failure is an independent gate signal and must be + # preserved even if a (pull_request_target) variant happens to be green. + counters = mod.reap( + {"CI": True}, + { + "statuses": [ + { + "context": "CI / Platform (Go) (pull_request)", + "status": "failure", + }, + { + "context": "CI / Platform (Go) (pull_request_target)", + "status": "success", + }, + ], + }, + "db3b7a93e31adc0cb072a6d177d92dd73275a191", + ) + + assert counters["compensated_governance_shadow"] == 0 + assert counters["preserved_pr_without_push_success"] == 1 + assert posted == [] + + +def test_reap_preserves_non_governance_no_push_shadow_when_target_passed(monkeypatch): + mod = load_reaper() + posted = [] + monkeypatch.setattr( + mod, + "post_compensating_status", + lambda sha, context, target_url, *, description="", dry_run=False: posted.append( + context + ), + ) + + # A no-push workflow that is NOT in the governance allowlist must be + # preserved even when its (pull_request_target) variant is green. + counters = mod.reap( + {"custom-audit": False}, + { + "statuses": [ + { + "context": "custom-audit / check (pull_request_review)", + "status": "failure", + }, + { + "context": "custom-audit / check (pull_request_target)", + "status": "success", + }, + ], + }, + "db3b7a93e31adc0cb072a6d177d92dd73275a191", + ) + + assert counters["compensated_governance_shadow"] == 0 + assert counters["compensated"] == 0 + assert posted == [] + + +def test_reap_compensates_retired_sop_tier_check_shadow_when_target_passed(monkeypatch): + mod = load_reaper() + posted = [] + + def fake_post(sha, context, target_url, *, description="", dry_run=False): + posted.append((sha, context, target_url, description, dry_run)) + + monkeypatch.setattr(mod, "post_compensating_status", fake_post) + + counters = mod.reap( + {"sop-tier-check": False}, + { + "statuses": [ + { + "context": "sop-tier-check / tier-verify (pull_request)", + "status": "failure", + "target_url": "https://git.example.test/tier-pr", + }, + { + "context": "sop-tier-check / tier-verify (pull_request_target)", + "status": "success", + }, + ], + }, + "db3b7a93e31adc0cb072a6d177d92dd73275a191", + ) + + assert counters["compensated_governance_shadow"] == 1 + assert counters["preserved_governance_without_target_success"] == 0 + assert posted == [ + ( + "db3b7a93e31adc0cb072a6d177d92dd73275a191", + "sop-tier-check / tier-verify (pull_request)", + "https://git.example.test/tier-pr", + mod.GOVERNANCE_SHADOW_COMPENSATION_DESCRIPTION, + False, + ), + ] + + +def test_reap_compensates_retired_sop_tier_check_when_missing_from_trigger_map(monkeypatch): + """The retired sop-tier-check workflow file is intentionally removed, so the + real workflow trigger map will not contain it. It must still be compensatable + because it is explicitly allowlisted as a retired governance shadow.""" + mod = load_reaper() + posted = [] + + def fake_post(sha, context, target_url, *, description="", dry_run=False): + posted.append((sha, context, target_url, description, dry_run)) + + monkeypatch.setattr(mod, "post_compensating_status", fake_post) + + counters = mod.reap( + # Deliberately omit sop-tier-check from the trigger map. + {}, + { + "statuses": [ + { + "context": "sop-tier-check / tier-verify (pull_request)", + "status": "failure", + "target_url": "https://git.example.test/tier-pr", + }, + { + "context": "sop-tier-check / tier-verify (pull_request_target)", + "status": "success", + }, + ], + }, + "db3b7a93e31adc0cb072a6d177d92dd73275a191", + ) + + assert counters["compensated_governance_shadow"] == 1 + assert counters["preserved_governance_without_target_success"] == 0 + assert posted == [ + ( + "db3b7a93e31adc0cb072a6d177d92dd73275a191", + "sop-tier-check / tier-verify (pull_request)", + "https://git.example.test/tier-pr", + mod.GOVERNANCE_SHADOW_COMPENSATION_DESCRIPTION, + False, + ), + ] + + +def test_reap_preserves_active_governance_shadow_when_missing_from_trigger_map(monkeypatch): + """Active governance workflows must be explicitly known-no-push in the trigger + map. If the parser/discovery misses them, the reaper must fail-closed and + preserve their shadow rather than auto-green it.""" + mod = load_reaper() + posted = [] + monkeypatch.setattr( + mod, + "post_compensating_status", + lambda sha, context, target_url, *, description="", dry_run=False: posted.append( + context + ), + ) + + counters = mod.reap( + # Deliberately omit qa-review from the trigger map. + {}, + { + "statuses": [ + { + "context": "qa-review / approved (pull_request_review)", + "status": "failure", + }, + { + "context": "qa-review / approved (pull_request_target)", + "status": "success", + }, + ], + }, + "db3b7a93e31adc0cb072a6d177d92dd73275a191", + ) + + assert counters["compensated_governance_shadow"] == 0 + assert counters["compensated"] == 0 + assert posted == [] + + +@pytest.mark.parametrize( + "context", + [ + "gate-check-v3 / gate (pull_request)", + "reserved-path-review / check (pull_request_review)", + "lint-required-no-paths / lint (pull_request)", + "lint-required-context-exists-in-bp / lint (pull_request_review)", + "audit-force-merge / audit (pull_request)", + "status-reaper / reap (pull_request_review)", + "umbrella-reaper / reap (pull_request)", + ], +) +def test_reap_preserves_named_non_governance_no_push_shadows(context, monkeypatch): + """Real merge-control/lint/audit workflows that are NOT in the governance + allowlist must be preserved even when they have no push trigger and their + (pull_request_target) variant is green. Auto-greening these would mask real + failures.""" + mod = load_reaper() + posted = [] + monkeypatch.setattr( + mod, + "post_compensating_status", + lambda sha, context, target_url, *, description="", dry_run=False: posted.append( + context + ), + ) + + workflow_name = context.split(" / ", 1)[0] + counters = mod.reap( + {workflow_name: False}, + { + "statuses": [ + { + "context": context, + "status": "failure", + }, + { + "context": context.replace( + " (pull_request)", " (pull_request_target)" + ).replace(" (pull_request_review)", " (pull_request_target)"), + "status": "success", + }, + ], + }, + "db3b7a93e31adc0cb072a6d177d92dd73275a191", + ) + + assert counters["compensated_governance_shadow"] == 0 + assert counters["compensated"] == 0 + assert posted == []