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(