fix(release): expose reconciliation decisions in run summaries

The successful scheduled reconciliation at run 33972475922 retained a credential-containment hold and dispatched no retry. Put safe decision messages in the Actions summary so a green reconciler is not mistaken for delivered releases. Narrow empty-discovery wording because mutable channels and missing runs are not qualified. Preserve containment, retry budgets and dispatch behaviour; cover summary output and empty discovery with focused tests.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-05 16:23:47 +01:00
parent ddafcf5330
commit 4f61c581b9
2 changed files with 77 additions and 12 deletions
@@ -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"<pre>{html.escape(message)}</pre>\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
@@ -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 <not delivered>")
subject.report_decision("second decision")
summary = path.read_text(encoding="utf-8")
self.assertTrue(summary.startswith("Existing summary\n"))
self.assertIn("held &lt;not delivered&gt;", 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)