fix(renovate): keep auxiliary image updates open for review

Auxiliary-only Renovate updates now stay open for manual review. Guard coverage and workflow lint passed.
This commit is contained in:
okxlin
2026-11-13 00:35:36 +08:00
committed by GitHub
parent 96afa1b369
commit a0b102aa4e
4 changed files with 76 additions and 66 deletions
+29 -26
View File
@@ -161,11 +161,38 @@ def compare_compose(
service_count,
changed_services,
primary_services,
"close",
"review",
"multi-service compose changed only auxiliary services",
)
def aggregate_decisions(decisions: list[ComposeDecision], *, seen_compose: bool) -> dict:
if not seen_compose:
return {
"decision": "allow",
"reason": "no compose files changed",
"details": [],
}
manual_review = [d for d in decisions if d.outcome == "review"]
blocking_unknown = [d for d in decisions if d.outcome == "unknown"]
if manual_review and not blocking_unknown:
summary = "; ".join(
f"{d.app}:{','.join(d.changed_services)}" for d in manual_review
)
return {
"decision": "review",
"reason": f"auxiliary-only update needs full-stack review ({summary})",
"details": [d.__dict__ for d in decisions],
}
return {
"decision": "allow",
"reason": "keep PR open",
"details": [d.__dict__ for d in decisions],
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--repo", required=True)
@@ -221,31 +248,7 @@ def main() -> int:
)
)
if not seen_compose:
result = {
"decision": "allow",
"reason": "no compose files changed",
"details": [],
}
else:
closable = [d for d in decisions if d.outcome == "close"]
blocking_unknown = [d for d in decisions if d.outcome == "unknown"]
primary_changed = [d for d in decisions if d.outcome == "allow"]
if closable and not blocking_unknown and not primary_changed:
summary = "; ".join(
f"{d.app}:{','.join(d.changed_services)}" for d in closable
)
result = {
"decision": "close",
"reason": f"sidecar-only Renovate update detected ({summary})",
"details": [d.__dict__ for d in decisions],
}
else:
result = {
"decision": "allow",
"reason": "keep PR open",
"details": [d.__dict__ for d in decisions],
}
result = aggregate_decisions(decisions, seen_compose=seen_compose)
json.dump(result, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
+6 -4
View File
@@ -515,7 +515,7 @@ class RenovateAppVersionTests(unittest.TestCase):
self.assertEqual("wait", too_soon["decision"])
self.assertEqual("limit", too_many["decision"])
def test_retry_workflow_and_sidecar_branch_cleanup_are_guarded(self):
def test_retry_workflow_and_sidecar_updates_are_reviewed_without_branch_cleanup(self):
retry_workflow = (
REPO_ROOT / ".github" / "workflows" / "renovate-rate-limit-retry.yml"
).read_text(encoding="utf-8")
@@ -527,9 +527,11 @@ class RenovateAppVersionTests(unittest.TestCase):
self.assertIn("actions: write", retry_workflow)
self.assertIn("renovate_retry_gate.py", retry_workflow)
self.assertIn("automatic retry after Docker Hub 429", retry_workflow)
self.assertIn('[[ "$head_repo" == "$REPO" ]]', sidecar_workflow)
self.assertIn('selfhosted-renovate/*|renovate/*', sidecar_workflow)
self.assertIn("git/refs/heads/$head_ref", sidecar_workflow)
self.assertIn("decision == 'review'", sidecar_workflow)
self.assertIn("gh pr comment", sidecar_workflow)
self.assertNotIn("gh pr close", sidecar_workflow)
self.assertNotIn("git/refs/heads/$head_ref", sidecar_workflow)
def test_image_tag_strips_digest_from_tagged_image(self):
module = load_module()
+33 -4
View File
@@ -14,6 +14,7 @@ SPEC.loader.exec_module(GUARD)
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
PRIMARY_SERVICES_PATH = REPOSITORY_ROOT / ".github" / "renovate-primary-services.json"
RENOVATE_CONFIG_PATH = REPOSITORY_ROOT / ".github" / "renovate-docker.json"
SIDECAR_GUARD_WORKFLOW_PATH = REPOSITORY_ROOT / ".github" / "workflows" / "renovate-sidecar-guard.yml"
def compose(**images: str) -> dict:
@@ -55,14 +56,14 @@ class ResolvePrimaryServicesTests(unittest.TestCase):
class InferredPrimaryDecisionTests(unittest.TestCase):
def test_closes_weblate_sidecar_only_update(self) -> None:
def test_marks_weblate_sidecar_only_update_for_manual_review(self) -> None:
base = compose(cache="valkey:9.1.0", database="postgres:18", weblate="weblate:latest")
head = compose(cache="valkey:9.1.1", database="postgres:18", weblate="weblate:latest")
primary = GUARD.resolve_primary_services("weblate", [], base, head)
decision = GUARD.compare_compose("weblate", "apps/weblate/latest/docker-compose.yml", base, head, primary)
self.assertEqual(decision.outcome, "close")
self.assertEqual(decision.outcome, "review")
self.assertEqual(decision.changed_services, ["cache"])
def test_allows_inferred_primary_update(self) -> None:
@@ -84,6 +85,24 @@ class InferredPrimaryDecisionTests(unittest.TestCase):
self.assertEqual(decision.detail, "single-service compose")
class DecisionAggregationTests(unittest.TestCase):
def test_auxiliary_only_update_emits_manual_review_decision(self) -> None:
decision = GUARD.ComposeDecision(
app="firecrawl",
path="apps/firecrawl/2.11.359/docker-compose.yml",
services_total=2,
changed_services=["firecrawl-playwright"],
primary_services=["firecrawl"],
outcome="review",
detail="multi-service compose changed only auxiliary services",
)
result = GUARD.aggregate_decisions([decision], seen_compose=True)
self.assertEqual(result["decision"], "review")
self.assertIn("firecrawl:firecrawl-playwright", result["reason"])
class RepositoryPolicyTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
@@ -119,7 +138,7 @@ class RepositoryPolicyTests(unittest.TestCase):
self.primary_services["dify"],
)
self.assertEqual(decision.outcome, "close")
self.assertEqual(decision.outcome, "review")
def test_safeline_components_are_grouped_behind_management_service(self) -> None:
self.assertEqual(self.primary_services["safeline"], ["safeline-mgt"])
@@ -173,7 +192,7 @@ class RepositoryPolicyTests(unittest.TestCase):
self.primary_services["uuwaf"],
)
self.assertEqual(decision.outcome, "close")
self.assertEqual(decision.outcome, "review")
def test_langflow_updates_require_manual_compatibility_review(self) -> None:
self.assertTrue(
@@ -184,5 +203,15 @@ class RepositoryPolicyTests(unittest.TestCase):
)
class WorkflowSafetyTests(unittest.TestCase):
def test_sidecar_only_updates_are_reviewed_without_closing_or_deleting_branches(self) -> None:
workflow = SIDECAR_GUARD_WORKFLOW_PATH.read_text(encoding="utf-8")
self.assertIn("decision == 'review'", workflow)
self.assertIn("gh pr comment", workflow)
self.assertNotIn("gh pr close", workflow)
self.assertNotIn("git/refs/heads", workflow)
if __name__ == "__main__":
unittest.main()