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",