Fix Podcaster source_artifacts contract (#488)

* Fix podcaster source_artifacts contract Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Tighten Podcaster smoke payload Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: jmservera <jmservera@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed Jun 15, 2026 at 17:51 UTC 26bc8445c8a1c80384fae7571ddba0561772f192
4 files changed +253 -27
.github/workflows/podcaster-handoff-smoke.yml
+101 -15
@@ -16,7 +16,7 @@ on:
16 required: true
17 type: string
18 article_sha256:
19 - description: 'Optional 64-character article SHA-256 to include in the dry-run payload.'
19 + description: 'Optional 64-character article SHA-256 override; when omitted the checked-out article file is hashed.'
20 required: false
21 default: ''
22 type: string
@@ -56,37 +56,123 @@ jobs:
56 echo "::error::Podcaster smoke test requires PODCASTER_ENDPOINT variable and PODCASTER_API_KEY secret."
57 exit 1
58 fi
59 - MANIFEST_ARGS=()
60 - if [ -n "$ARTICLE_SHA256" ]; then
61 - if ! [[ "$ARTICLE_SHA256" =~ ^[0-9a-f]{64}$ ]]; then
62 - echo "::error::article_sha256 must be lowercase 64-character hex when provided."
63 - exit 1
64 - fi
65 - mkdir -p .podcaster-smoke
66 - python3 - <<'PY' "$WEEK" "$ARTICLE_SHA256" .podcaster-smoke/publish-manifest.json
59 + if [ ! -f "$ARTICLE_PATH" ]; then
60 + echo "::error::article_path must exist in the checked-out repository so the smoke test exercises the real article-content handoff path."
61 + exit 1
62 + fi
63 + mkdir -p .podcaster-smoke
64 + python3 - <<'PY' "$WEEK" "$ARTICLE_PATH" "$ARTICLE_SHA256" .podcaster-smoke/publish-manifest.json
65 + import hashlib
66 import json
67 import sys
68 from pathlib import Path
69
71 - week, article_sha, manifest_path = sys.argv[1:]
70 + week, article_path, article_sha, manifest_path = sys.argv[1:]
71 + article = Path(article_path)
72 + if not article.is_file():
73 + raise SystemExit(f"article_path does not exist: {article_path}")
74 + if article_sha:
75 + if len(article_sha) != 64 or article_sha.lower() != article_sha or any(ch not in "0123456789abcdef" for ch in article_sha):
76 + raise SystemExit("article_sha256 must be lowercase 64-character hex when provided.")
77 + else:
78 + article_sha = hashlib.sha256(article.read_bytes()).hexdigest()
79 +
80 + def digest(label: str) -> str:
81 + return hashlib.sha256(f"{week}:{label}".encode("utf-8")).hexdigest()
82 +
83 manifest = {
84 "week": week,
85 "run_mode": "normal",
86 "candidate": {"summary_sha256": article_sha},
87 "analysis": {"ai_status": "ai"},
88 "promotion": {"eligible": True, "decision": "promote"},
78 - "source_artifacts": [],
89 + "source_artifacts": [
90 + {
91 + "role": "raw_github",
92 + "path": f"data/raw/{week}.json",
93 + "name": f"{week}-raw-github",
94 + "sha256": digest("raw"),
95 + "generated_at": "2026-06-08T10:15:00Z",
96 + "crawled_at": "2026-06-08T10:12:00Z",
97 + "source_status": "fresh",
98 + "exists": True,
99 + "size_bytes": article.stat().st_size,
100 + "freshness": {"status": "fresh", "reasons": []},
101 + "provenance": {
102 + "path": f"data/raw/{week}.json",
103 + "sha256": digest("raw"),
104 + },
105 + "same_day_reuse": {"status": "reused", "source": "smoke"},
106 + "sources_requested": ["github"],
107 + "sources_succeeded": ["github"],
108 + "sources_failed": [],
109 + },
110 + {
111 + "role": "published_summary",
112 + "path": article_path,
113 + "href": f"https://example.com/{week}/source-index.json",
114 + "sha256": digest("published-summary"),
115 + "generated_at": "2026-06-08T11:20:00Z",
116 + "exists": True,
117 + "size_bytes": article.stat().st_size,
118 + "provenance": {
119 + "path": article_path,
120 + "sha256": article_sha,
121 + },
122 + "source_reuse_summary": {"reused": False},
123 + },
124 + {
125 + "role": "operator_packet",
126 + "name": "weekly-publishing-packet",
127 + "uri": f"https://example.com/{week}/publishing-packet.json",
128 + "artifact_checksum": digest("publishing-packet"),
129 + "schema_checksum": digest("publishing-packet-schema"),
130 + "source_config_checksum": digest("publishing-packet-config"),
131 + "source_artifact_provenance": {"source": "smoke-manifest"},
132 + "week": week,
133 + },
134 + ],
135 }
136 Path(manifest_path).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
137 PY
82 - MANIFEST_ARGS=(--manifest .podcaster-smoke/publish-manifest.json)
83 - fi
138 + python3 - <<'PY' "$WEEK" "$ARTICLE_URL" "$ARTICLE_PATH" "$PUBLISH_RUN_ID" .podcaster-smoke/publish-manifest.json
139 + import sys
140 + from pathlib import Path
141 +
142 + from scripts.podcaster_handoff import build_payload
143 +
144 + week, article_url, article_path, publish_run_id, manifest_path = sys.argv[1:]
145 + payload = build_payload(
146 + week=week,
147 + article_url=article_url,
148 + article_path=article_path,
149 + publish_run_id=publish_run_id,
150 + publish_mode="normal",
151 + manifest_path=Path(manifest_path),
152 + podcast_config_path=Path("config/podcast.json"),
153 + podcaster_dry_run=True,
154 + )
155 + required_fields = (
156 + "source_artifacts",
157 + "podcast_config",
158 + "script_directions",
159 + "spotify_publish",
160 + "article_content",
161 + "article_title",
162 + "dry_run",
163 + )
164 + missing = [field for field in required_fields if field not in payload]
165 + if missing:
166 + raise SystemExit(f"smoke payload is missing expected real-handoff fields: {', '.join(missing)}")
167 + if not payload["source_artifacts"]:
168 + raise SystemExit("smoke payload must include representative source_artifacts")
169 + PY
170 python3 scripts/podcaster_handoff.py \
171 --week "$WEEK" \
172 --article-url "$ARTICLE_URL" \
173 --article-path "$ARTICLE_PATH" \
174 --publish-run-id "$PUBLISH_RUN_ID" \
175 --publish-mode normal \
176 + --manifest .podcaster-smoke/publish-manifest.json \
177 --podcast-config config/podcast.json \
91 - --podcaster-dry-run \
92 - "${MANIFEST_ARGS[@]}"
178 + --podcaster-dry-run
scripts/podcaster_handoff.py
+44 -8
@@ -106,23 +106,59 @@ def _load_podcast_config(path: Path | None) -> dict[str, Any]:
106 return payload
107
108
109 -def _source_artifact_refs(manifest: dict[str, Any]) -> list[dict[str, str]]:
110 - refs: list[dict[str, str]] = []
109 +def _source_artifact_refs(manifest: dict[str, Any]) -> list[dict[str, Any]]:
110 + refs: list[dict[str, Any]] = []
111 for artifact in manifest.get("source_artifacts", []):
112 if not isinstance(artifact, dict):
113 continue
114 - ref: dict[str, str] = {}
115 - for key in ("role", "path", "sha256", "generated_at"):
114 + ref: dict[str, Any] = {}
115 + for key in (
116 + "role",
117 + "path",
118 + "name",
119 + "sha256",
120 + "artifact_checksum",
121 + "week",
122 + "crawled_at",
123 + "generated_at",
124 + "source_status",
125 + "source_config_checksum",
126 + "schema_checksum",
127 + ):
128 value = artifact.get(key)
129 if isinstance(value, str) and value:
130 ref[key] = value
119 - freshness = artifact.get("freshness")
120 - if isinstance(freshness, dict) and isinstance(freshness.get("status"), str):
121 - ref["freshness_status"] = freshness["status"]
122 - for key in ("url", "artifact_url"):
131 + exists = artifact.get("exists")
132 + if isinstance(exists, bool):
133 + ref["exists"] = exists
134 + size_bytes = artifact.get("size_bytes")
135 + if isinstance(size_bytes, int) and not isinstance(size_bytes, bool) and size_bytes >= 0:
136 + ref["size_bytes"] = size_bytes
137 + for key in ("url", "href", "uri"):
138 value = artifact.get(key)
139 if isinstance(value, str) and value.startswith(("https://", "http://localhost:", "http://127.0.0.1:")):
140 ref[key] = value
141 + artifact_url = artifact.get("artifact_url")
142 + if (
143 + "url" not in ref
144 + and isinstance(artifact_url, str)
145 + and artifact_url.startswith(("https://", "http://localhost:", "http://127.0.0.1:"))
146 + ):
147 + ref["url"] = artifact_url
148 + for key in (
149 + "freshness",
150 + "provenance",
151 + "same_day_reuse",
152 + "source_artifact_provenance",
153 + "source_reuse_summary",
154 + ):
155 + value = artifact.get(key)
156 + if isinstance(value, dict):
157 + ref[key] = value
158 + for key in ("sources_requested", "sources_succeeded", "sources_failed"):
159 + value = artifact.get(key)
160 + if isinstance(value, list):
161 + ref[key] = value
162 if ref:
163 refs.append(ref)
164 return refs
tests/test_pipeline.py
+28
@@ -464,6 +464,34 @@ class WorkflowConfigTests(unittest.TestCase):
464 self.assertNotIn("echo $PODCASTER_API_KEY", run_script)
465 self.assertNotIn("curl", run_script)
466
467 + def test_podcaster_smoke_workflow_exercises_real_weekly_payload_shape(self) -> None:
468 + workflow_path = Path(".github/workflows/podcaster-handoff-smoke.yml")
469 + workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))
470 +
471 + inputs = workflow[True]["workflow_dispatch"]["inputs"]
472 + self.assertIn("week", inputs)
473 + self.assertIn("article_url", inputs)
474 + self.assertIn("article_path", inputs)
475 + self.assertIn("article_sha256", inputs)
476 + self.assertEqual(inputs["article_sha256"]["default"], "")
477 +
478 + smoke_job = workflow["jobs"]["smoke"]
479 + smoke_step = next((s for s in smoke_job["steps"] if s.get("name") == "Smoke test Podcaster dry run"), None)
480 + self.assertIsNotNone(smoke_step)
481 + run_script = smoke_step["run"]
482 + self.assertIn('if [ ! -f "$ARTICLE_PATH" ]', run_script)
483 + self.assertIn("hashlib.sha256(article.read_bytes()).hexdigest()", run_script)
484 + self.assertIn('"source_artifacts": [', run_script)
485 + self.assertIn('"same_day_reuse"', run_script)
486 + self.assertIn("build_payload(", run_script)
487 + self.assertIn('"podcast_config"', run_script)
488 + self.assertIn('"script_directions"', run_script)
489 + self.assertIn('"spotify_publish"', run_script)
490 + self.assertIn('"article_content"', run_script)
491 + self.assertIn("--manifest .podcaster-smoke/publish-manifest.json", run_script)
492 + self.assertIn("--podcast-config config/podcast.json", run_script)
493 + self.assertIn("--podcaster-dry-run", run_script)
494 +
495 def test_publish_workflow_uses_candidate_manifest_before_promotion(self) -> None:
496 workflow_path = Path(".github/workflows/crawl-and-publish.yml")
497 workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))
tests/test_podcaster_handoff.py
+80 -4
@@ -1,5 +1,6 @@
1 import io
2 import json
3 +import sys
4 import tempfile
5 import unittest
6 from pathlib import Path
@@ -36,8 +37,23 @@ class PodcasterHandoffTests(unittest.TestCase):
37 "analysis": {"ai_status": ai_status},
38 "promotion": {"eligible": True, "decision": "promote"},
39 "source_artifacts": [
39 - {"role": "raw", "path": "data/raw/2026-W23.json", "sha256": "b" * 64},
40 - {"role": "blob", "url": "https://example.blob.core.windows.net/artifacts/source.json"},
40 + {
41 + "role": "raw",
42 + "path": "data/raw/2026-W23.json",
43 + "sha256": "b" * 64,
44 + "generated_at": "2026-06-08T10:15:00Z",
45 + "freshness": {"status": "fresh", "reasons": []},
46 + "provenance": {
47 + "path": "data/raw/2026-W23.json",
48 + "sha256": "b" * 64,
49 + },
50 + },
51 + {
52 + "role": "blob",
53 + "artifact_url": "https://example.blob.core.windows.net/artifacts/source.json",
54 + "exists": True,
55 + "size_bytes": 1024,
56 + },
57 ],
58 }
59 ),
@@ -77,10 +93,27 @@ class PodcasterHandoffTests(unittest.TestCase):
93 self.assertEqual(
94 payload["source_artifacts"],
95 [
80 - {"role": "raw", "path": "data/raw/2026-W23.json", "sha256": "b" * 64},
81 - {"role": "blob", "url": "https://example.blob.core.windows.net/artifacts/source.json"},
96 + {
97 + "role": "raw",
98 + "path": "data/raw/2026-W23.json",
99 + "sha256": "b" * 64,
100 + "generated_at": "2026-06-08T10:15:00Z",
101 + "freshness": {"status": "fresh", "reasons": []},
102 + "provenance": {
103 + "path": "data/raw/2026-W23.json",
104 + "sha256": "b" * 64,
105 + },
106 + },
107 + {
108 + "role": "blob",
109 + "url": "https://example.blob.core.windows.net/artifacts/source.json",
110 + "exists": True,
111 + "size_bytes": 1024,
112 + },
113 ],
114 )
115 + self.assertNotIn("artifact_url", payload["source_artifacts"][1])
116 + self.assertNotIn("freshness_status", payload["source_artifacts"][0])
117 self.assertNotIn("force", payload)
118 self.assertNotIn("dry_run", payload)
119 # article_content and article_title from the article file
@@ -89,6 +122,49 @@ class PodcasterHandoffTests(unittest.TestCase):
122 self.assertEqual(payload["article_summary"], "Week 23 summary.")
123 self.assertIn("Body content here.", payload["article_content"])
124
125 + def test_smoke_payload_matches_real_weekly_handoff_shape(self) -> None:
126 + podcaster_root = Path(__file__).resolve().parents[2] / "SquadScope-Podcaster"
127 + if not podcaster_root.exists():
128 + self.skipTest("SquadScope-Podcaster checkout is not available for contract validation")
129 +
130 + sys.path.insert(0, str(podcaster_root))
131 + try:
132 + from podcaster.validation import validate_payload
133 + finally:
134 + sys.path.pop(0)
135 +
136 + tests_root = Path(__file__).resolve().parent
137 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
138 + manifest = self._write_manifest(Path(tmpdir))
139 + article_dir = Path(tmpdir) / "content" / "weekly" / "2026"
140 + article_dir.mkdir(parents=True)
141 + (article_dir / "W23.md").write_text(
142 + "---\ntitle: Week 23 Report\nsummary: Week 23 summary.\n---\n# Week 23 Report\nBody content here.\n",
143 + encoding="utf-8",
144 + )
145 +
146 + payload = podcaster_handoff.build_payload(
147 + week="2026-W23",
148 + article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
149 + article_path="content/weekly/2026/W23.md",
150 + publish_run_id="123456789",
151 + publish_mode="normal",
152 + manifest_path=manifest,
153 + podcast_config_path=Path(__file__).resolve().parents[1] / "config" / "podcast.json",
154 + podcaster_dry_run=True,
155 + repo_root=Path(tmpdir),
156 + )
157 +
158 + self.assertEqual(validate_payload(payload), [])
159 + self.assertTrue(payload["dry_run"])
160 + self.assertIn("source_artifacts", payload)
161 + self.assertTrue(payload["source_artifacts"])
162 + self.assertIn("podcast_config", payload)
163 + self.assertIn("script_directions", payload)
164 + self.assertIn("spotify_publish", payload)
165 + self.assertEqual(payload["article_title"], "Week 23 Report")
166 + self.assertEqual(payload["article_summary"], "Week 23 summary.")
167 +
168 def test_podcaster_dry_run_sets_payload_flag(self) -> None:
169 payload = podcaster_handoff.build_payload(
170 week="2026-W23",