diff --git a/scripts/release_control/reconcile_release_convergence.py b/scripts/release_control/reconcile_release_convergence.py index 09bb49b95..553deb1e6 100644 --- a/scripts/release_control/reconcile_release_convergence.py +++ b/scripts/release_control/reconcile_release_convergence.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import html from datetime import datetime import json import os @@ -478,6 +479,20 @@ def flatten_pages(pages: Iterable[object], key: str | None = None) -> list[objec return values +def report_decision(message: str) -> None: + """Expose safe decision text, not private API responses, in the run summary.""" + print(message) + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a", encoding="utf-8") as output: + output.write( + "### Release convergence reconciliation\n\n" + "A successful reconciliation job is not evidence of successful " + "release convergence or installed customer health.\n\n" + f"
{html.escape(message)}
\n\n" + ) + + def reconcile(github: GitHub, run_id: int, max_attempts: int) -> None: repository = github.repository repository_state = github.api(f"repos/{repository}") @@ -499,7 +514,7 @@ def reconcile(github: GitHub, run_id: int, max_attempts: int) -> None: path=CONVERGENCE_PATH, ) if run.get("status") != "completed" or run.get("conclusion") not in TERMINAL_FAILURES: - print(f"Convergence run {run_id} no longer has terminal convergence debt; no action.") + report_decision(f"Convergence run {run_id} no longer has terminal convergence debt; no action.") return title = run.get("display_title") match = DISPLAY_TITLE.fullmatch(title) if isinstance(title, str) else None @@ -528,7 +543,7 @@ def reconcile(github: GitHub, run_id: int, max_attempts: int) -> None: raise ReconciliationError("convergence history omitted the requested run") newest = max(matching_runs, key=lambda item: timestamp(item.get("created_at"), "run")) if newest.get("id") != run_id: - print(f"A newer convergence run already owns {title}; no action.") + report_decision(f"A newer convergence run already owns {title}; no action.") return try: release = github.api(f"repos/{repository}/releases/tags/{tag}") @@ -554,21 +569,21 @@ def reconcile(github: GitHub, run_id: int, max_attempts: int) -> None: ) < 50: submitted = github.post(f"repos/{repository}/actions/runs/{run_id}/rerun") if submitted: - print( + report_decision( f"Renewed pre-commit convergence owner {run_id} " f"for active source {source_run_id}." ) else: - print( + report_decision( f"DRY RUN: Would renew pre-commit convergence owner {run_id} " f"for active source {source_run_id}." ) return - print(f"{tag} has no immutable activation commit; no convergence retry was dispatched.") + report_decision(f"{tag} has no immutable activation commit; no convergence retry was dispatched.") return if github.unchanged_credential_containment_block(run_id): - print( + report_decision( f"Convergence run {run_id} is held by unchanged private credential containment; " "no unattended retry was dispatched." ) @@ -604,16 +619,16 @@ def reconcile(github: GitHub, run_id: int, max_attempts: int) -> None: if item.get("head_sha") == run.get("head_sha") ) if attempts >= max_attempts: - print( + report_decision( f"{title} remains failed after the {attempts}-attempt retry budget; " "no unchanged-control retry was dispatched." ) return submitted = github.post(f"repos/{repository}/actions/runs/{run_id}/rerun") if submitted: - print(f"Re-ran current-control convergence {run_id} for committed {tag}.") + report_decision(f"Re-ran current-control convergence {run_id} for committed {tag}.") else: - print( + report_decision( f"DRY RUN: Would re-run current-control convergence {run_id} " f"for committed {tag}." ) @@ -641,12 +656,12 @@ def reconcile(github: GitHub, run_id: int, max_attempts: int) -> None: payload, ) if submitted: - print( + report_decision( f"Dispatched fresh convergence controls after observing {default_commit} " f"for committed {tag}; the failed run used {run.get('head_sha')}." ) else: - print( + report_decision( f"DRY RUN: Would dispatch fresh convergence controls after observing " f"{default_commit} for committed {tag}; the failed run used " f"{run.get('head_sha')}." @@ -708,7 +723,10 @@ def main() -> int: try: run_ids = discover(github) if args.latest else [args.run_id] if not run_ids: - print("No current immutable release has unattended convergence debt.") + report_decision( + "No failed run selected for retry among current immutable channel heads. " + "Mutable releases and missing convergence runs are not qualified by this check." + ) return 0 for run_id in run_ids: assert run_id is not None diff --git a/scripts/release_control/reconcile_release_convergence_test.py b/scripts/release_control/reconcile_release_convergence_test.py index 1b2653608..25cb288c0 100644 --- a/scripts/release_control/reconcile_release_convergence_test.py +++ b/scripts/release_control/reconcile_release_convergence_test.py @@ -2,8 +2,12 @@ from __future__ import annotations +import argparse import contextlib import io +import os +from pathlib import Path +import tempfile import unittest from unittest.mock import patch import subprocess @@ -256,6 +260,49 @@ class FakeGitHub: return False +class DecisionSummaryTests(unittest.TestCase): + def test_local_execution_needs_no_summary_file(self): + with patch.dict(os.environ, {}, clear=True), contextlib.redirect_stdout(io.StringIO()) as output: + subject.report_decision("No action.") + self.assertEqual("No action.\n", output.getvalue()) + + def test_summary_appends_escaped_decisions_without_claiming_delivery(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "summary.md" + path.write_text("Existing summary\n", encoding="utf-8") + with patch.dict(os.environ, {"GITHUB_STEP_SUMMARY": str(path)}): + subject.report_decision("held ") + subject.report_decision("second decision") + summary = path.read_text(encoding="utf-8") + self.assertTrue(summary.startswith("Existing summary\n")) + self.assertIn("held <not delivered>", summary) + self.assertIn("second decision", summary) + self.assertIn("not evidence of successful release convergence", summary) + + def test_empty_discovery_does_not_claim_absent_debt(self): + args = argparse.Namespace(repository="rcourtman/Pulse", run_id=None, + latest=True, max_attempts=5, dry_run=True) + with patch.object(subject, "parse_args", return_value=args), \ + patch.object(subject, "discover", return_value=[]), \ + patch.object(subject, "report_decision") as report: + self.assertEqual(0, subject.main()) + message = report.call_args.args[0] + self.assertIn("No failed run selected for retry", message) + self.assertIn("missing convergence runs are not qualified", message) + + def test_credential_hold_is_visible_without_retry(self): + github = FakeGitHub(current_controls=True) + github.unchanged_credential_containment_block = lambda run_id: True + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "summary.md" + with patch.dict(os.environ, {"GITHUB_STEP_SUMMARY": str(path)}): + subject.reconcile(github, github.run_id, 5) + summary = path.read_text(encoding="utf-8") + self.assertEqual([], github.posts) + self.assertIn("held by unchanged private credential containment", summary) + self.assertIn("no unattended retry was dispatched", summary) + + class JobLogTests(unittest.TestCase): def test_uses_captured_sanitised_job_reader_with_private_auth(self): github = subject.GitHub("rcourtman/Pulse", "gh", mutate=False)