feat(podcaster-handoff): surface podcaster job_id in handoff notice (#587) (#589)
Operators can now capture the podcaster run id from the wired handoff path. The podcaster job_id and status are escaped for GitHub Actions notice output to prevent workflow-command injection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Juan Manuel Servera committed
Jul 20, 2026 at 16:23 UTC
43953b9d8bcfbf31940d289fdc4b6ce92ba3343d
2 files changed
+55
-2
scripts/podcaster_handoff.py
+9
-2
@@ -123,6 +123,11 @@ def article_url_from_page_path(base_url: str, page_path: str) -> str:
123
return urljoin(base, f"weekly/{slug}/")
124
125
126
+def _escape_gha_data(value: str) -> str:
127
+ """Escape data for the message portion of a GitHub Actions workflow command."""
128
+ return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
129
+
130
+
131
def validate_endpoint(endpoint: str) -> None:
132
parsed = urlparse(endpoint)
133
if parsed.scheme not in {"https", "http"} or not parsed.netloc:
@@ -839,11 +844,13 @@ def main(argv: list[str] | None = None) -> int:
844
require_merged=args.require_merged,
845
manifest=manifest,
846
)
842
- post_handoff(endpoint, api_key, payload, timeout=args.timeout)
847
+ response = post_handoff(endpoint, api_key, payload, timeout=args.timeout)
848
except PodcasterHandoffError as exc:
849
print(f"::error::Podcaster handoff failed: {exc}")
850
return 1
846
- print("::notice::Podcaster handoff accepted.")
851
+ job_id = _escape_gha_data(str(response.get("job_id", "")))
852
+ status = _escape_gha_data(str(response.get("status", "")))
853
+ print(f"::notice::Podcaster handoff completed (job_id={job_id}, status={status}).")
854
return 0
855
856
tests/test_podcaster_handoff.py
+46
@@ -126,6 +126,9 @@ class PodcasterHandoffTests(unittest.TestCase):
126
yearly_path.mkdir(parents=True, exist_ok=True)
127
(yearly_path / "2026.md").write_text(yearly_narrative, encoding="utf-8")
128
129
+ def test_escape_gha_data_escapes_workflow_command_data(self) -> None:
130
+ self.assertEqual(podcaster_handoff._escape_gha_data("a\r\nb%c"), "a%0D%0Ab%25c")
131
+
132
def test_build_payload_uses_required_fields_and_real_optional_values(self) -> None:
133
tests_root = Path(__file__).resolve().parent
134
with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
@@ -458,6 +461,49 @@ class PodcasterHandoffTests(unittest.TestCase):
461
urlopen_mock.assert_not_called()
462
self.assertIn("Podcaster handoff skipped", stdout.getvalue())
463
464
+ def test_main_prints_podcaster_job_id_and_status_notice(self) -> None:
465
+ response = {
466
+ "job_id": "podcast-2026-W30-abc12345",
467
+ "status": "accepted",
468
+ "errors": [],
469
+ }
470
+ payload = {"week": "2026-W30"}
471
+ with (
472
+ mock.patch.object(podcaster_handoff, "build_payload", return_value=payload),
473
+ mock.patch.object(
474
+ podcaster_handoff, "post_handoff", return_value=response
475
+ ) as post_handoff_mock,
476
+ mock.patch.dict(
477
+ podcaster_handoff.os.environ, {"PODCASTER_API_KEY": "super-secret-value"}
478
+ ),
479
+ mock.patch("sys.stdout", new_callable=io.StringIO) as stdout,
480
+ ):
481
+ exit_code = podcaster_handoff.main(
482
+ [
483
+ "--week",
484
+ "2026-W30",
485
+ "--article-url",
486
+ "https://jmservera.github.io/SquadScope/weekly/2026/w30/",
487
+ "--article-path",
488
+ "content/weekly/2026/W30.md",
489
+ "--publish-run-id",
490
+ "123456789",
491
+ "--endpoint",
492
+ "http://localhost:7071/api/generate",
493
+ ]
494
+ )
495
+
496
+ self.assertEqual(exit_code, 0)
497
+ post_handoff_mock.assert_called_once_with(
498
+ "http://localhost:7071/api/generate",
499
+ "super-secret-value",
500
+ payload,
501
+ timeout=podcaster_handoff.DEFAULT_TIMEOUT_SECONDS,
502
+ )
503
+ notice = stdout.getvalue()
504
+ self.assertIn("job_id=podcast-2026-W30-abc12345", notice)
505
+ self.assertIn("status=accepted", notice)
506
+
507
def test_post_handoff_sends_auth_header_without_logging_value(self) -> None:
508
response = _FakeHTTPResponse(
509
json.dumps(