From ec462ad9647ab4c1c80d80a46a4c3db36f68a99f Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:32:38 +0100 Subject: [PATCH] fix(release): inspect convergence logs through safe Actions reader Scheduled reconciliation fails when gh api refuses ANSI-bearing Actions logs. Use the dedicated sanitising log reader without disabling terminal protection, preserving private authentication and fail-closed evidence handling. Focused reconciliation and policy tests pass; a read-only live job probe retains failure evidence without ESC bytes. Change-source: pulse-maintainer --- .../subsystems/deployment-installability.md | 16 +++++++++++ .../reconcile_release_convergence.py | 17 +++++++---- .../reconcile_release_convergence_test.py | 28 +++++++++++++++++++ 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 2e19c32e9..695dab5ef 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -5097,3 +5097,19 @@ allowlist, the drift refusal, the hotfix path, and the minor soak; summaries whose scope, format, and attachments the server fixes. The delivery surface (`/api/admin/reports/schedules`) and tenant-local storage are unchanged. + +### Convergence log-reader compatibility (5 September 2026) + +Scheduled reconciliation run 33939926237 failed when `gh api` refused terminal +escape sequences in an Actions job log. Credential-containment evidence now +uses `gh run view --repo … --job … --log`, whose dedicated log reader neutralises +terminal controls. It does not enable `--allow-escape-sequences`. Private logs +remain captured for marker inspection, not printed; authentication and log-read +failures retain the existing fail-closed behaviour. This changes evidence +transport only, not containment, retry budgets or promotion authority. + +Verification: 27 reconciliation tests and 46 release-promotion-policy tests +passed. A read-only probe of job 101235205647 retained the expected failure text +without ESC bytes. Private containment classification and successful scheduled +reconciliation still require post-integration evidence; this is not customer +convergence or release qualification. diff --git a/scripts/release_control/reconcile_release_convergence.py b/scripts/release_control/reconcile_release_convergence.py index a7869a732..d0828ff2e 100644 --- a/scripts/release_control/reconcile_release_convergence.py +++ b/scripts/release_control/reconcile_release_convergence.py @@ -325,14 +325,19 @@ class GitHub: return value def job_log(self, repository: str, job_id: int, *, token: str = "") -> str: + # Actions logs routinely contain ANSI colour sequences. gh api refuses + # those bytes for terminal safety; use the dedicated log reader, which + # neutralises control sequences, rather than disabling that protection. + # Keep captured private logs internal: callers inspect only a marker. return self._run( [ - "api", - "-H", - "Accept: application/vnd.github+json", - "-H", - "X-GitHub-Api-Version: 2026-03-10", - f"repos/{repository}/actions/jobs/{job_id}/logs", + "run", + "view", + "--repo", + repository, + "--job", + str(job_id), + "--log", ], token=token, ) diff --git a/scripts/release_control/reconcile_release_convergence_test.py b/scripts/release_control/reconcile_release_convergence_test.py index 064de4685..db0a09beb 100644 --- a/scripts/release_control/reconcile_release_convergence_test.py +++ b/scripts/release_control/reconcile_release_convergence_test.py @@ -5,6 +5,8 @@ from __future__ import annotations import contextlib import io import unittest +from unittest.mock import patch +import subprocess import reconcile_release_convergence as subject @@ -253,6 +255,32 @@ class FakeGitHub: return False +class JobLogTests(unittest.TestCase): + def test_uses_captured_sanitised_job_reader_with_private_auth(self): + github = subject.GitHub("rcourtman/Pulse", "gh", mutate=False) + log = "Require credential containment\tcheck\tcredential containment gate: BLOCKED\n" + with patch.object(subject.subprocess, "run", return_value=subprocess.CompletedProcess( + [], 0, stdout=log, stderr="" + )) as command, contextlib.redirect_stdout(io.StringIO()) as output: + self.assertEqual(log, github.job_log( + subject.PRIVATE_REPOSITORY, 123, token="test-private-token" + )) + self.assertEqual("", output.getvalue()) + args, kwargs = command.call_args + self.assertEqual(["gh", "run", "view", "--repo", subject.PRIVATE_REPOSITORY, + "--job", "123", "--log"], args[0]) + self.assertTrue(kwargs["capture_output"]) + self.assertEqual("test-private-token", kwargs["env"]["GH_TOKEN"]) + + def test_unavailable_job_logs_fail_closed(self): + github = subject.GitHub("rcourtman/Pulse", "gh", mutate=False) + with patch.object(subject.subprocess, "run", return_value=subprocess.CompletedProcess( + [], 1, stdout="", stderr="log unavailable" + )): + with self.assertRaisesRegex(subject.ReconciliationError, "log unavailable"): + github.job_log(subject.PRIVATE_REPOSITORY, 123) + + class CredentialContainmentTests(unittest.TestCase): def github( self,