fix(podcaster-handoff): audited force-replace is handoff-eligible; gated replays skip cleanly (#587) (#588)

* fix(podcaster-handoff): audited force-replace is handoff-eligible; gated replays skip cleanly (#587) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(handoff): gate clean-skip to plain restore replays only (#587 review) Tighten _is_gated_replay to run_mode == 'restore' so a missing/unknown or other non-normal run_mode stays fail-closed in build_payload instead of being silently skipped, per Copilot review. Add regression coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(handoff): load manifest once; document audited force-replace handoff (#587 review) - build_payload accepts an optional preloaded manifest so main() parses the manifest a single time (feeds both _is_gated_replay and payload build), avoiding duplicate IO/JSON and read-skew between the two checks. - operator-guide.md: document that audited force-replace corrections DO call Podcaster while plain restore replays are a clean skip (not a failure). - Add regression test for the preloaded-manifest path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed Jul 20, 2026 at 15:46 UTC b0b48153260db9d10a75d9602a95f90866dd978c
3 files changed +231 -33
docs/operator-guide.md
+1 -1
@@ -101,7 +101,7 @@ To ask the separate Podcaster service to generate an episode after a normal week
101 - Actions variable `PODCASTER_ENDPOINT`, for example `https://<function-app-name>.azurewebsites.net/api/generate` or local testing URL `http://localhost:7071/api/generate`
102 - Actions secret `PODCASTER_API_KEY`
103
104 -The workflow sends `week`, `article_url`, `article_path`, `article_sha256` when available, `publish_run_id`, `publish_mode`, and source artifact references after the normal article deploy succeeds. Dry-run, candidate-only, restore, force-replace, no-AI, and failed runs do not call Podcaster. If either endpoint value is missing, the handoff is skipped. The API key is sent only as the `x-podcaster-api-key` header and must not be printed, logged, or committed. Handoff failure is non-critical and does not roll back or block article publication.
104 +The workflow sends `week`, `article_url`, `article_path`, `article_sha256` when available, `publish_run_id`, `publish_mode`, and source artifact references after the normal article deploy succeeds. Normal runs and **audited force-replace corrections** — a `force-replace` policy manifest that carries an operator `audit.actor` and `audit.reason` (the same signal that gates promotion) — call Podcaster so a material content correction refreshes the episode. Plain (non-audited) `restore` replays are deliberately excluded and are a clean skip (a notice, exit 0 — not a failure), so replays never spam the Podcaster and never fail the sync job. Dry-run, candidate-only, no-AI, and failed runs also do not call Podcaster. If either endpoint value is missing, the handoff is skipped. The API key is sent only as the `x-podcaster-api-key` header and must not be printed, logged, or committed. Handoff failure is non-critical and does not roll back or block article publication.
105
106 ### Step 6: Enable GitHub Pages
107
scripts/podcaster_handoff.py
+52 -2
@@ -601,7 +601,7 @@ def _manifest_allows_handoff(manifest: dict[str, Any], *, week: str, publish_mod
601 promotion = manifest.get("promotion")
602 return (
603 manifest.get("week") == week
604 - and manifest.get("run_mode") == "normal"
604 + and (manifest.get("run_mode") == "normal" or _is_audited_force_replace(manifest))
605 and publish_mode == "normal"
606 and isinstance(analysis, dict)
607 and analysis.get("ai_status") == "ai"
@@ -611,6 +611,41 @@ def _manifest_allows_handoff(manifest: dict[str, Any], *, week: str, publish_mod
611 )
612
613
614 +def _is_audited_force_replace(manifest: dict[str, Any]) -> bool:
615 + promotion = manifest.get("promotion")
616 + audit = manifest.get("audit")
617 + return (
618 + isinstance(promotion, dict)
619 + and promotion.get("policy") == "force-replace"
620 + and isinstance(audit, dict)
621 + and bool(audit.get("actor"))
622 + and bool(audit.get("reason"))
623 + )
624 +
625 +
626 +def _is_gated_replay(manifest: dict[str, Any], *, week: str) -> bool:
627 + """Well-formed, promotion-eligible manifest that is deliberately excluded from
628 + handoff (a plain, non-audited ``restore`` replay). Such a manifest is a clean
629 + skip, not an error. Anything else -- malformed manifests, a missing/unknown
630 + run_mode, or other non-normal modes -- returns False here and stays fail-closed
631 + in build_payload, so a genuinely broken manifest is never silently skipped.
632 + """
633 + if not manifest:
634 + return False
635 + analysis = manifest.get("analysis")
636 + promotion = manifest.get("promotion")
637 + well_formed_promotable = (
638 + manifest.get("week") == week
639 + and isinstance(analysis, dict)
640 + and analysis.get("ai_status") == "ai"
641 + and isinstance(promotion, dict)
642 + and promotion.get("eligible") is True
643 + and promotion.get("decision") == "promote"
644 + )
645 + gated_mode = manifest.get("run_mode") == "restore" and not _is_audited_force_replace(manifest)
646 + return well_formed_promotable and gated_mode
647 +
648 +
649 def build_payload(
650 *,
651 week: str,
@@ -624,8 +659,10 @@ def build_payload(
659 repo_root: Path | None = None,
660 breaking_news: str | None = None,
661 require_merged: bool = False,
662 + manifest: dict[str, Any] | None = None,
663 ) -> dict[str, Any]:
628 - manifest = _load_manifest(manifest_path)
664 + if manifest is None:
665 + manifest = _load_manifest(manifest_path)
666 if not _manifest_allows_handoff(manifest, week=week, publish_mode=publish_mode):
667 raise PodcasterHandoffError("Publish manifest is not eligible for Podcaster handoff.")
668 normalized_path = normalize_page_path(article_path)
@@ -776,6 +813,18 @@ def main(argv: list[str] | None = None) -> int:
813 print(f"::notice::Podcaster handoff skipped for publish mode {args.publish_mode}.")
814 return 0
815
816 + try:
817 + manifest = _load_manifest(args.manifest)
818 + except PodcasterHandoffError as exc:
819 + print(f"::error::Podcaster handoff failed: {exc}")
820 + return 1
821 + if _is_gated_replay(manifest, week=args.week):
822 + print(
823 + "::notice::Podcaster handoff skipped: publish manifest is a non-audited replay "
824 + "(not eligible for handoff)."
825 + )
826 + return 0
827 +
828 try:
829 payload = build_payload(
830 week=args.week,
@@ -788,6 +837,7 @@ def main(argv: list[str] | None = None) -> int:
837 podcaster_dry_run=args.podcaster_dry_run,
838 breaking_news=args.breaking_news,
839 require_merged=args.require_merged,
840 + manifest=manifest,
841 )
842 post_handoff(endpoint, api_key, payload, timeout=args.timeout)
843 except PodcasterHandoffError as exc:
tests/test_podcaster_handoff.py
+178 -30
@@ -61,39 +61,49 @@ class _BalancedHtmlParser(HTMLParser):
61
62 class PodcasterHandoffTests(unittest.TestCase):
63 def _write_manifest(
64 - self, base: Path, *, run_mode: str = "normal", ai_status: str = "ai"
64 + self,
65 + base: Path,
66 + *,
67 + run_mode: str = "normal",
68 + ai_status: str = "ai",
69 + policy: str | None = None,
70 + audit: dict | None = None,
71 ) -> Path:
72 manifest = base / "publish-manifest.json"
67 - manifest.write_text(
68 - json.dumps(
73 + promotion: dict[str, object] = {"eligible": True, "decision": "promote"}
74 + if policy is not None:
75 + promotion["policy"] = policy
76 + payload = {
77 + "week": "2026-W23",
78 + "run_id": "123456789",
79 + "run_mode": run_mode,
80 + "candidate": {"summary_sha256": "a" * 64, "content_sha256": "c" * 64},
81 + "analysis": {"ai_status": ai_status},
82 + "promotion": promotion,
83 + "source_artifacts": [
84 {
70 - "week": "2026-W23",
71 - "run_id": "123456789",
72 - "run_mode": run_mode,
73 - "candidate": {"summary_sha256": "a" * 64, "content_sha256": "c" * 64},
74 - "analysis": {"ai_status": ai_status},
75 - "promotion": {"eligible": True, "decision": "promote"},
76 - "source_artifacts": [
77 - {
78 - "role": "raw",
79 - "path": "data/raw/2026-W23.json",
80 - "sha256": "b" * 64,
81 - "generated_at": "2026-06-08T10:15:00Z",
82 - "freshness": {"status": "fresh", "reasons": []},
83 - "provenance": {
84 - "path": "data/raw/2026-W23.json",
85 - "sha256": "b" * 64,
86 - },
87 - },
88 - {
89 - "role": "blob",
90 - "artifact_url": "https://example.blob.core.windows.net/artifacts/source.json",
91 - "exists": True,
92 - "size_bytes": 1024,
93 - },
94 - ],
95 - }
96 - ),
85 + "role": "raw",
86 + "path": "data/raw/2026-W23.json",
87 + "sha256": "b" * 64,
88 + "generated_at": "2026-06-08T10:15:00Z",
89 + "freshness": {"status": "fresh", "reasons": []},
90 + "provenance": {
91 + "path": "data/raw/2026-W23.json",
92 + "sha256": "b" * 64,
93 + },
94 + },
95 + {
96 + "role": "blob",
97 + "artifact_url": "https://example.blob.core.windows.net/artifacts/source.json",
98 + "exists": True,
99 + "size_bytes": 1024,
100 + },
101 + ],
102 + }
103 + if audit is not None:
104 + payload["audit"] = audit
105 + manifest.write_text(
106 + json.dumps(payload),
107 encoding="utf-8",
108 )
109 return manifest
@@ -556,6 +566,144 @@ class PodcasterHandoffTests(unittest.TestCase):
566 manifest_path=no_ai_manifest,
567 )
568
569 + def test_plain_restore_replay_skips_without_calling_podcaster(self) -> None:
570 + tests_root = Path(__file__).resolve().parent
571 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
572 + manifest = self._write_manifest(Path(tmpdir), run_mode="restore")
573 + with (
574 + mock.patch.object(podcaster_handoff.request, "urlopen") as urlopen_mock,
575 + mock.patch.dict(
576 + podcaster_handoff.os.environ, {"PODCASTER_API_KEY": "super-secret-value"}
577 + ),
578 + mock.patch("sys.stdout", new_callable=io.StringIO) as stdout,
579 + ):
580 + exit_code = podcaster_handoff.main(
581 + [
582 + "--week",
583 + "2026-W23",
584 + "--article-url",
585 + "https://jmservera.github.io/SquadScope/weekly/2026/w23/",
586 + "--article-path",
587 + "content/weekly/2026/W23.md",
588 + "--publish-run-id",
589 + "123456789",
590 + "--publish-mode",
591 + "normal",
592 + "--manifest",
593 + str(manifest),
594 + "--endpoint",
595 + "http://localhost:7071/api/generate",
596 + ]
597 + )
598 +
599 + self.assertEqual(exit_code, 0)
600 + urlopen_mock.assert_not_called()
601 + self.assertIn("skipped", stdout.getvalue())
602 + self.assertIn("non-audited replay", stdout.getvalue())
603 +
604 + def test_audited_force_replace_restore_is_handoff_eligible(self) -> None:
605 + tests_root = Path(__file__).resolve().parent
606 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
607 + manifest = self._write_manifest(
608 + Path(tmpdir),
609 + run_mode="restore",
610 + policy="force-replace",
611 + audit={"actor": "jmservera", "reason": "W30 press-inclusion correction"},
612 + )
613 +
614 + payload = podcaster_handoff.build_payload(
615 + week="2026-W23",
616 + article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
617 + article_path="content/weekly/2026/W23.md",
618 + publish_run_id="123456789",
619 + publish_mode="normal",
620 + manifest_path=manifest,
621 + )
622 + loaded_manifest = json.loads(manifest.read_text(encoding="utf-8"))
623 +
624 + self.assertEqual(payload["week"], "2026-W23")
625 + self.assertFalse(podcaster_handoff._is_gated_replay(loaded_manifest, week="2026-W23"))
626 +
627 + def test_normal_publish_manifest_is_handoff_eligible(self) -> None:
628 + tests_root = Path(__file__).resolve().parent
629 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
630 + manifest = self._write_manifest(Path(tmpdir), run_mode="normal")
631 +
632 + payload = podcaster_handoff.build_payload(
633 + week="2026-W23",
634 + article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
635 + article_path="content/weekly/2026/W23.md",
636 + publish_run_id="123456789",
637 + publish_mode="normal",
638 + manifest_path=manifest,
639 + )
640 + loaded_manifest = json.loads(manifest.read_text(encoding="utf-8"))
641 +
642 + self.assertEqual(payload["week"], "2026-W23")
643 + self.assertFalse(podcaster_handoff._is_gated_replay(loaded_manifest, week="2026-W23"))
644 +
645 + def test_manifest_allows_audited_force_replace_but_not_plain_restore(self) -> None:
646 + tests_root = Path(__file__).resolve().parent
647 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
648 + base = Path(tmpdir)
649 + audited_manifest = self._write_manifest(
650 + base,
651 + run_mode="restore",
652 + policy="force-replace",
653 + audit={"actor": "jmservera", "reason": "W30 press-inclusion correction"},
654 + )
655 + audited = json.loads(audited_manifest.read_text(encoding="utf-8"))
656 + plain_restore_manifest = self._write_manifest(base, run_mode="restore")
657 + plain_restore = json.loads(plain_restore_manifest.read_text(encoding="utf-8"))
658 +
659 + self.assertTrue(
660 + podcaster_handoff._manifest_allows_handoff(
661 + audited, week="2026-W23", publish_mode="normal"
662 + )
663 + )
664 + self.assertFalse(
665 + podcaster_handoff._manifest_allows_handoff(
666 + plain_restore, week="2026-W23", publish_mode="normal"
667 + )
668 + )
669 +
670 + def test_non_restore_modes_are_not_gated_replays(self) -> None:
671 + # A gated replay is specifically a plain (non-audited) restore. Any other
672 + # non-normal or missing run_mode must NOT be treated as a clean skip -- it
673 + # stays fail-closed via build_payload -- so a broken manifest is never
674 + # silently skipped (regression for jmservera/SquadScope#587 review).
675 + tests_root = Path(__file__).resolve().parent
676 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
677 + base = Path(tmpdir)
678 + missing_mode = self._write_manifest(base, run_mode="normal")
679 + missing = json.loads(missing_mode.read_text(encoding="utf-8"))
680 + del missing["run_mode"]
681 + candidate_only = self._write_manifest(base, run_mode="candidate-only")
682 + candidate = json.loads(candidate_only.read_text(encoding="utf-8"))
683 +
684 + self.assertFalse(podcaster_handoff._is_gated_replay(missing, week="2026-W23"))
685 + self.assertFalse(podcaster_handoff._is_gated_replay(candidate, week="2026-W23"))
686 +
687 + def test_build_payload_uses_preloaded_manifest_without_reloading(self) -> None:
688 + # main() loads the manifest once for _is_gated_replay and passes it into
689 + # build_payload, which must reuse it rather than re-reading the file.
690 + tests_root = Path(__file__).resolve().parent
691 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
692 + manifest_path = self._write_manifest(Path(tmpdir), run_mode="normal")
693 + preloaded = json.loads(manifest_path.read_text(encoding="utf-8"))
694 + with mock.patch.object(
695 + podcaster_handoff, "_load_manifest", side_effect=AssertionError("reloaded")
696 + ):
697 + payload = podcaster_handoff.build_payload(
698 + week="2026-W23",
699 + article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
700 + article_path="content/weekly/2026/W23.md",
701 + publish_run_id="123456789",
702 + publish_mode="normal",
703 + manifest=preloaded,
704 + )
705 + self.assertEqual(payload["week"], "2026-W23")
706 +
707 def test_missing_manifest_path_raises_fail_closed(self) -> None:
708 tests_root = Path(__file__).resolve().parent
709 with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir: