| 1 | #!/usr/bin/env python3 |
| 2 | from __future__ import annotations |
| 3 | |
| 4 | import argparse |
| 5 | import hashlib |
| 6 | import json |
| 7 | import os |
| 8 | import re |
| 9 | from datetime import date |
| 10 | from html.parser import HTMLParser |
| 11 | from pathlib import Path |
| 12 | from typing import Any |
| 13 | from urllib import error, request |
| 14 | from urllib.parse import urljoin, urlparse |
| 15 | |
| 16 | AUTH_HEADER = "x-podcaster-api-key" |
| 17 | DEFAULT_TIMEOUT_SECONDS = 180 |
| 18 | DEFAULT_PODCAST_CONFIG_PATH = Path(__file__).resolve().parent.parent / "config" / "podcast.json" |
| 19 | REPO_ROOT = Path(__file__).resolve().parent.parent |
| 20 | MAX_ARTICLE_CONTENT_CHARS = 50_000 |
| 21 | MAX_SPOTIFY_TITLE_CHARS = 200 |
| 22 | MAX_SPOTIFY_DESCRIPTION_CHARS = 4_000 |
| 23 | MAX_MONTH_SYNTHESIS_WORDS = 300 |
| 24 | MAX_YEARLY_NARRATIVE_WORDS = 500 |
| 25 | _VOID_HTML_TAGS = frozenset( |
| 26 | { |
| 27 | "area", |
| 28 | "base", |
| 29 | "br", |
| 30 | "col", |
| 31 | "embed", |
| 32 | "hr", |
| 33 | "img", |
| 34 | "input", |
| 35 | "link", |
| 36 | "meta", |
| 37 | "param", |
| 38 | "source", |
| 39 | "track", |
| 40 | "wbr", |
| 41 | } |
| 42 | ) |
| 43 | |
| 44 | |
| 45 | class PodcasterHandoffError(RuntimeError): |
| 46 | pass |
| 47 | |
| 48 | |
| 49 | def parse_args(argv: list[str] | None = None) -> argparse.Namespace: |
| 50 | parser = argparse.ArgumentParser( |
| 51 | description="Notify Podcaster after a SquadScope weekly article is published." |
| 52 | ) |
| 53 | parser.add_argument("--week", required=True, help="ISO week slug, e.g. 2026-W23.") |
| 54 | parser.add_argument("--article-url", required=True, help="Published SquadScope article URL.") |
| 55 | parser.add_argument( |
| 56 | "--article-path", required=True, help="Published SquadScope article content path." |
| 57 | ) |
| 58 | parser.add_argument( |
| 59 | "--publish-run-id", required=True, help="GitHub Actions run ID that published the article." |
| 60 | ) |
| 61 | parser.add_argument( |
| 62 | "--publish-mode", |
| 63 | default="normal", |
| 64 | help="Publish mode; only normal is eligible for Podcaster handoff.", |
| 65 | ) |
| 66 | parser.add_argument( |
| 67 | "--manifest", |
| 68 | type=Path, |
| 69 | help="Optional publish manifest used for article hash/source artifact metadata.", |
| 70 | ) |
| 71 | parser.add_argument( |
| 72 | "--podcaster-dry-run", |
| 73 | action="store_true", |
| 74 | help="Ask Podcaster to validate without generating an episode; intended only for the manual smoke workflow.", |
| 75 | ) |
| 76 | parser.add_argument( |
| 77 | "--podcast-config", |
| 78 | type=Path, |
| 79 | default=None, |
| 80 | help="Path to podcast config JSON (default: config/podcast.json relative to repo root).", |
| 81 | ) |
| 82 | parser.add_argument( |
| 83 | "--breaking-news", |
| 84 | default=None, |
| 85 | help="Optional last-moment news or important information to include in this podcast episode.", |
| 86 | ) |
| 87 | parser.add_argument( |
| 88 | "--require-merged", |
| 89 | action="store_true", |
| 90 | help="Fail closed unless the article file exists locally and its sha256 matches the " |
| 91 | "manifest candidate.content_sha256. Use after the weekly article is merged to main so " |
| 92 | "the podcaster is never triggered for an unpublished/stub article.", |
| 93 | ) |
| 94 | parser.add_argument("--endpoint", default=os.environ.get("PODCASTER_ENDPOINT", "")) |
| 95 | parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_SECONDS) |
| 96 | return parser.parse_args(argv) |
| 97 | |
| 98 | |
| 99 | WEEKLY_CONTENT_PREFIX = "content/weekly/" |
| 100 | |
| 101 | |
| 102 | def normalize_page_path(page_path: str) -> str: |
| 103 | """Reduce an absolute Actions page path to its repo-relative form. |
| 104 | |
| 105 | The generate job emits page_path as an absolute runner path on GitHub |
| 106 | Actions (see scripts/generate_content.py); mirror the workflow's |
| 107 | GITHUB_WORKSPACE normalization by reducing any absolute path to the |
| 108 | repo-relative segment beginning at content/weekly/. |
| 109 | """ |
| 110 | path = page_path.strip().replace("\\", "/") |
| 111 | index = path.find(WEEKLY_CONTENT_PREFIX) |
| 112 | if index != -1: |
| 113 | path = path[index:] |
| 114 | return path.lstrip("/") |
| 115 | |
| 116 | |
| 117 | def article_url_from_page_path(base_url: str, page_path: str) -> str: |
| 118 | base = base_url.rstrip("/") + "/" |
| 119 | path = normalize_page_path(page_path) |
| 120 | if not path.startswith(WEEKLY_CONTENT_PREFIX) or not path.endswith(".md"): |
| 121 | raise PodcasterHandoffError(f"Cannot derive weekly article URL from page path: {page_path}") |
| 122 | slug = path.removeprefix(WEEKLY_CONTENT_PREFIX).removesuffix(".md").lower() |
| 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: |
| 134 | raise PodcasterHandoffError("PODCASTER_ENDPOINT must be an absolute HTTP(S) URL.") |
| 135 | if parsed.scheme == "http" and parsed.hostname not in {"localhost", "127.0.0.1", "::1"}: |
| 136 | raise PodcasterHandoffError( |
| 137 | "PODCASTER_ENDPOINT may use HTTP only for localhost or loopback addresses (127.0.0.1, ::1)." |
| 138 | ) |
| 139 | |
| 140 | |
| 141 | def _load_manifest(path: Path | None) -> dict[str, Any]: |
| 142 | if path is None: |
| 143 | return {} |
| 144 | if not path.exists(): |
| 145 | raise PodcasterHandoffError( |
| 146 | f"Publish manifest path was provided but does not exist: {path}" |
| 147 | ) |
| 148 | try: |
| 149 | payload = json.loads(path.read_text(encoding="utf-8")) |
| 150 | except (OSError, json.JSONDecodeError) as exc: |
| 151 | raise PodcasterHandoffError(f"Publish manifest could not be read: {path}") from exc |
| 152 | if not isinstance(payload, dict): |
| 153 | raise PodcasterHandoffError(f"Publish manifest must be a JSON object: {path}") |
| 154 | return payload |
| 155 | |
| 156 | |
| 157 | def _load_podcast_config(path: Path | None) -> dict[str, Any]: |
| 158 | """Load the podcast config file containing podcast_config and script_directions.""" |
| 159 | config_path = path if path is not None else DEFAULT_PODCAST_CONFIG_PATH |
| 160 | if not config_path.exists(): |
| 161 | return {} |
| 162 | try: |
| 163 | payload = json.loads(config_path.read_text(encoding="utf-8")) |
| 164 | except (OSError, json.JSONDecodeError) as exc: |
| 165 | raise PodcasterHandoffError(f"Podcast config could not be read: {config_path}") from exc |
| 166 | if not isinstance(payload, dict): |
| 167 | raise PodcasterHandoffError(f"Podcast config must be a JSON object: {config_path}") |
| 168 | return payload |
| 169 | |
| 170 | |
| 171 | def _source_artifact_refs(manifest: dict[str, Any]) -> list[dict[str, Any]]: |
| 172 | def _string_list(value: Any) -> list[str] | None: |
| 173 | if not isinstance(value, list): |
| 174 | return None |
| 175 | filtered = [item for item in value if isinstance(item, str) and item] |
| 176 | return filtered or None |
| 177 | |
| 178 | refs: list[dict[str, Any]] = [] |
| 179 | for artifact in manifest.get("source_artifacts", []): |
| 180 | if not isinstance(artifact, dict): |
| 181 | continue |
| 182 | ref: dict[str, Any] = {} |
| 183 | for key in ( |
| 184 | "role", |
| 185 | "path", |
| 186 | "name", |
| 187 | "sha256", |
| 188 | "artifact_checksum", |
| 189 | "week", |
| 190 | "crawled_at", |
| 191 | "generated_at", |
| 192 | "source_status", |
| 193 | "source_config_checksum", |
| 194 | "schema_checksum", |
| 195 | ): |
| 196 | value = artifact.get(key) |
| 197 | if isinstance(value, str) and value: |
| 198 | ref[key] = value |
| 199 | exists = artifact.get("exists") |
| 200 | if isinstance(exists, bool): |
| 201 | ref["exists"] = exists |
| 202 | size_bytes = artifact.get("size_bytes") |
| 203 | if isinstance(size_bytes, int) and not isinstance(size_bytes, bool) and size_bytes >= 0: |
| 204 | ref["size_bytes"] = size_bytes |
| 205 | for key in ("url", "href", "uri"): |
| 206 | value = artifact.get(key) |
| 207 | if isinstance(value, str) and value.startswith( |
| 208 | ("https://", "http://localhost:", "http://127.0.0.1:") |
| 209 | ): |
| 210 | ref[key] = value |
| 211 | artifact_url = artifact.get("artifact_url") |
| 212 | if ( |
| 213 | "url" not in ref |
| 214 | and isinstance(artifact_url, str) |
| 215 | and artifact_url.startswith(("https://", "http://localhost:", "http://127.0.0.1:")) |
| 216 | ): |
| 217 | ref["url"] = artifact_url |
| 218 | for key in ( |
| 219 | "freshness", |
| 220 | "provenance", |
| 221 | "same_day_reuse", |
| 222 | "source_artifact_provenance", |
| 223 | "source_reuse_summary", |
| 224 | ): |
| 225 | value = artifact.get(key) |
| 226 | if isinstance(value, dict): |
| 227 | ref[key] = value |
| 228 | for key in ("sources_requested", "sources_succeeded", "sources_failed"): |
| 229 | filtered = _string_list(artifact.get(key)) |
| 230 | if filtered is not None: |
| 231 | ref[key] = filtered |
| 232 | if ref: |
| 233 | refs.append(ref) |
| 234 | return refs |
| 235 | |
| 236 | |
| 237 | def _extract_title(content: str) -> str | None: |
| 238 | """Extract article title from YAML front matter or first # heading.""" |
| 239 | # Try YAML front matter first |
| 240 | if content.startswith("---"): |
| 241 | end = content.find("\n---", 3) |
| 242 | if end != -1: |
| 243 | frontmatter = content[3:end] |
| 244 | match = re.search(r"^title:\s*(.+)$", frontmatter, re.MULTILINE) |
| 245 | if match: |
| 246 | title = match.group(1).strip().strip("\"'") |
| 247 | if title: |
| 248 | return title |
| 249 | # Fall back to first # heading |
| 250 | match = re.search(r"^#\s+(.+)$", content, re.MULTILINE) |
| 251 | if match: |
| 252 | return match.group(1).strip() |
| 253 | return None |
| 254 | |
| 255 | |
| 256 | def _extract_frontmatter_field(content: str, field_name: str) -> str | None: |
| 257 | if not content.startswith("---"): |
| 258 | return None |
| 259 | end = content.find("\n---", 3) |
| 260 | if end == -1: |
| 261 | return None |
| 262 | frontmatter = content[3:end] |
| 263 | match = re.search(rf"^{re.escape(field_name)}:\s*(.+)$", frontmatter, re.MULTILINE) |
| 264 | if not match: |
| 265 | return None |
| 266 | value = match.group(1).strip().strip("\"'") |
| 267 | return value or None |
| 268 | |
| 269 | |
| 270 | def _render_template_value(value: Any, context: dict[str, Any]) -> Any: |
| 271 | if isinstance(value, dict): |
| 272 | return {key: _render_template_value(item, context) for key, item in value.items()} |
| 273 | if isinstance(value, list): |
| 274 | return [_render_template_value(item, context) for item in value] |
| 275 | if not isinstance(value, str): |
| 276 | return value |
| 277 | exact_match = re.fullmatch(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}", value) |
| 278 | if exact_match: |
| 279 | key = exact_match.group(1) |
| 280 | if key in context: |
| 281 | return context[key] |
| 282 | try: |
| 283 | return value.format(**context) |
| 284 | except KeyError as exc: |
| 285 | missing = exc.args[0] |
| 286 | raise PodcasterHandoffError( |
| 287 | f"spotify_publish template references unknown field: {missing}" |
| 288 | ) from exc |
| 289 | except (ValueError, IndexError) as exc: |
| 290 | raise PodcasterHandoffError( |
| 291 | f"spotify_publish template has invalid format syntax: {value!r}" |
| 292 | ) from exc |
| 293 | |
| 294 | |
| 295 | class _HTMLTruncator(HTMLParser): |
| 296 | def __init__(self, max_length: int) -> None: |
| 297 | super().__init__(convert_charrefs=False) |
| 298 | self.max_length = max_length |
| 299 | self.parts: list[str] = [] |
| 300 | self.open_tags: list[str] = [] |
| 301 | self.current_length = 0 |
| 302 | self.truncated = False |
| 303 | |
| 304 | def handle_starttag(self, tag: str, attrs) -> None: # type: ignore[override] |
| 305 | self._append_tag(self.get_starttag_text(), tag, push=True) |
| 306 | |
| 307 | def handle_startendtag(self, tag: str, attrs) -> None: # type: ignore[override] |
| 308 | self._append_tag(self.get_starttag_text(), tag, push=False) |
| 309 | |
| 310 | def handle_endtag(self, tag: str) -> None: # type: ignore[override] |
| 311 | normalized = tag.lower() |
| 312 | if self.truncated or normalized not in self.open_tags: |
| 313 | return |
| 314 | |
| 315 | closings: list[str] = [] |
| 316 | while self.open_tags: |
| 317 | open_tag = self.open_tags.pop() |
| 318 | closings.append(f"</{open_tag}>") |
| 319 | if open_tag == normalized: |
| 320 | break |
| 321 | |
| 322 | for closing in closings: |
| 323 | self._append(closing) |
| 324 | |
| 325 | def handle_data(self, data: str) -> None: |
| 326 | self._append_text(data) |
| 327 | |
| 328 | def handle_entityref(self, name: str) -> None: |
| 329 | self._append_atomic(f"&{name};") |
| 330 | |
| 331 | def handle_charref(self, name: str) -> None: |
| 332 | self._append_atomic(f"&#{name};") |
| 333 | |
| 334 | def handle_comment(self, data: str) -> None: |
| 335 | self._append_atomic(f"<!--{data}-->") |
| 336 | |
| 337 | def _append_tag(self, raw_tag: str | None, tag: str, *, push: bool) -> None: |
| 338 | if self.truncated or not raw_tag: |
| 339 | return |
| 340 | |
| 341 | normalized = tag.lower() |
| 342 | budget = self._closing_budget(extra_tag=normalized if push else None) |
| 343 | if self.current_length + len(raw_tag) + budget > self.max_length: |
| 344 | self.truncated = True |
| 345 | return |
| 346 | |
| 347 | self._append(raw_tag) |
| 348 | if push and normalized not in _VOID_HTML_TAGS: |
| 349 | self.open_tags.append(normalized) |
| 350 | |
| 351 | def _append_atomic(self, token: str) -> None: |
| 352 | if self.truncated or not token: |
| 353 | return |
| 354 | available = self.max_length - self.current_length - self._closing_budget() |
| 355 | if len(token) > available: |
| 356 | self.truncated = True |
| 357 | return |
| 358 | self._append(token) |
| 359 | |
| 360 | def _append_text(self, text: str) -> None: |
| 361 | if self.truncated or not text: |
| 362 | return |
| 363 | |
| 364 | available = self.max_length - self.current_length - self._closing_budget() |
| 365 | if available <= 0: |
| 366 | self.truncated = True |
| 367 | return |
| 368 | |
| 369 | piece = text[:available] |
| 370 | if piece: |
| 371 | self._append(piece) |
| 372 | if len(piece) < len(text): |
| 373 | self.truncated = True |
| 374 | |
| 375 | def _closing_budget(self, *, extra_tag: str | None = None) -> int: |
| 376 | budget = sum(len(f"</{tag}>") for tag in self.open_tags) |
| 377 | if extra_tag and extra_tag not in _VOID_HTML_TAGS: |
| 378 | budget += len(f"</{extra_tag}>") |
| 379 | return budget |
| 380 | |
| 381 | def _append(self, text: str) -> None: |
| 382 | self.parts.append(text) |
| 383 | self.current_length += len(text) |
| 384 | |
| 385 | def finish(self) -> str: |
| 386 | for tag in reversed(self.open_tags): |
| 387 | self._append(f"</{tag}>") |
| 388 | return "".join(self.parts) |
| 389 | |
| 390 | |
| 391 | def truncate_html(value: str, limit: int) -> str: |
| 392 | if len(value) <= limit: |
| 393 | return value |
| 394 | |
| 395 | truncator = _HTMLTruncator(limit) |
| 396 | truncator.feed(value) |
| 397 | truncator.close() |
| 398 | return truncator.finish() |
| 399 | |
| 400 | |
| 401 | def _truncate_text(value: str, limit: int) -> str: |
| 402 | return value[:limit] |
| 403 | |
| 404 | |
| 405 | def _truncate_words(value: str, limit: int) -> str: |
| 406 | words = value.split() |
| 407 | return " ".join(words[:limit]) |
| 408 | |
| 409 | |
| 410 | def _strip_frontmatter(content: str) -> str: |
| 411 | if not content.startswith("---"): |
| 412 | return content.strip() |
| 413 | end = content.find("\n---", 3) |
| 414 | if end == -1: |
| 415 | return content.strip() |
| 416 | return content[end + 4 :].strip() |
| 417 | |
| 418 | |
| 419 | def _extract_markdown_sections(content: str, headings: tuple[str, ...]) -> str | None: |
| 420 | body = _strip_frontmatter(content) |
| 421 | sections: list[str] = [] |
| 422 | for heading in headings: |
| 423 | match = re.search( |
| 424 | rf"^##\s+{re.escape(heading)}\s*$\n?(.*?)(?=^##\s+|\Z)", |
| 425 | body, |
| 426 | re.MULTILINE | re.DOTALL, |
| 427 | ) |
| 428 | if not match: |
| 429 | continue |
| 430 | section_body = match.group(1).strip() |
| 431 | section = f"## {heading}" |
| 432 | if section_body: |
| 433 | section += f"\n\n{section_body}" |
| 434 | sections.append(section) |
| 435 | combined = "\n\n".join(sections).strip() |
| 436 | return combined or None |
| 437 | |
| 438 | |
| 439 | def _read_historical_context(week: str, repo_root: Path) -> dict[str, str] | None: |
| 440 | match = re.fullmatch(r"(?P<year>\d{4})-W(?P<week>\d{1,2})", week) |
| 441 | if not match: |
| 442 | raise PodcasterHandoffError( |
| 443 | f"Week must use YYYY-WNN format for historical context lookup: {week}" |
| 444 | ) |
| 445 | |
| 446 | year = int(match.group("year")) |
| 447 | week_number = int(match.group("week")) |
| 448 | monday = date.fromisocalendar(year, week_number, 1) |
| 449 | |
| 450 | month_synthesis_path = ( |
| 451 | repo_root / "data" / "analyzed" / f"{year}-{monday.month:02d}-month-synthesis.md" |
| 452 | ) |
| 453 | yearly_narrative_path = repo_root / "content" / "yearly" / f"{year}.md" |
| 454 | |
| 455 | historical_context: dict[str, str] = {} |
| 456 | |
| 457 | if month_synthesis_path.exists(): |
| 458 | try: |
| 459 | month_synthesis = month_synthesis_path.read_text(encoding="utf-8") |
| 460 | except OSError as exc: |
| 461 | raise PodcasterHandoffError( |
| 462 | f"Month synthesis file exists but could not be read: {month_synthesis_path} ({exc})" |
| 463 | ) from exc |
| 464 | extracted_sections = _extract_markdown_sections( |
| 465 | month_synthesis, ("Month Synthesis", "Trend Arc") |
| 466 | ) |
| 467 | if extracted_sections: |
| 468 | historical_context["month_synthesis"] = _truncate_words( |
| 469 | extracted_sections, |
| 470 | MAX_MONTH_SYNTHESIS_WORDS, |
| 471 | ) |
| 472 | |
| 473 | if yearly_narrative_path.exists(): |
| 474 | try: |
| 475 | yearly_narrative = yearly_narrative_path.read_text(encoding="utf-8") |
| 476 | except OSError as exc: |
| 477 | raise PodcasterHandoffError( |
| 478 | f"Yearly narrative file exists but could not be read: {yearly_narrative_path} ({exc})" |
| 479 | ) from exc |
| 480 | extracted_yearly = _extract_markdown_sections(yearly_narrative, ("Year in Review",)) |
| 481 | if extracted_yearly: |
| 482 | historical_context["yearly_narrative"] = _truncate_words( |
| 483 | extracted_yearly, |
| 484 | MAX_YEARLY_NARRATIVE_WORDS, |
| 485 | ) |
| 486 | |
| 487 | return historical_context or None |
| 488 | |
| 489 | |
| 490 | def _resolve_spotify_publish( |
| 491 | config: dict[str, Any], *, week: str, article_title: str | None, article_summary: str | None |
| 492 | ) -> dict[str, Any]: |
| 493 | """Render spotify_publish templates into concrete values for the Podcaster API. |
| 494 | |
| 495 | Design: SquadScope resolves templates (title_template, description_template) |
| 496 | into final strings before sending. Podcaster receives ready-to-use metadata, |
| 497 | not raw templates — this keeps rendering logic in the source-of-truth repo. |
| 498 | """ |
| 499 | match = re.fullmatch(r"(?P<year>\d{4})-W(?P<week>\d{1,2})", week) |
| 500 | if not match: |
| 501 | raise PodcasterHandoffError( |
| 502 | f"Week must use YYYY-WNN format for spotify_publish templating: {week}" |
| 503 | ) |
| 504 | context: dict[str, Any] = { |
| 505 | "year": int(match.group("year")), |
| 506 | "week": int(match.group("week")), |
| 507 | "article_title": article_title or "", |
| 508 | "article_summary": article_summary or "", |
| 509 | } |
| 510 | resolved = _render_template_value(config, context) |
| 511 | title = resolved.pop("title_template", None) |
| 512 | if isinstance(title, str): |
| 513 | resolved["title"] = _truncate_text(title, MAX_SPOTIFY_TITLE_CHARS) |
| 514 | elif title is not None: |
| 515 | resolved["title"] = title |
| 516 | description = resolved.pop("description_template", None) |
| 517 | if isinstance(description, str): |
| 518 | resolved["description"] = truncate_html(description, MAX_SPOTIFY_DESCRIPTION_CHARS) |
| 519 | elif description is not None: |
| 520 | resolved["description"] = description |
| 521 | return resolved |
| 522 | |
| 523 | |
| 524 | def _read_article_content( |
| 525 | article_path: str, repo_root: Path = REPO_ROOT |
| 526 | ) -> tuple[str | None, str | None, str | None]: |
| 527 | """Read article file content and extract title. |
| 528 | |
| 529 | Returns (content, title, summary). Content is truncated to MAX_ARTICLE_CONTENT_CHARS. |
| 530 | Returns (None, None, None) if the file does not exist. |
| 531 | Raises PodcasterHandoffError if the file exists but cannot be read, or if |
| 532 | the resolved path escapes the repo root (path traversal prevention). |
| 533 | """ |
| 534 | resolved = (repo_root / article_path).resolve() |
| 535 | # Prevent path traversal — resolved path must stay within repo_root. |
| 536 | try: |
| 537 | resolved.relative_to(repo_root.resolve()) |
| 538 | except ValueError: |
| 539 | raise PodcasterHandoffError( |
| 540 | f"article_path resolves outside the repository root: {article_path}" |
| 541 | ) |
| 542 | if not resolved.exists(): |
| 543 | return None, None, None |
| 544 | try: |
| 545 | content = resolved.read_text(encoding="utf-8") |
| 546 | except OSError as exc: |
| 547 | raise PodcasterHandoffError( |
| 548 | f"Article file exists but could not be read: {resolved} ({exc})" |
| 549 | ) |
| 550 | if not content.strip(): |
| 551 | return None, None, None |
| 552 | title = _extract_frontmatter_field(content, "title") or _extract_title(content) |
| 553 | summary = _extract_frontmatter_field(content, "summary") |
| 554 | if len(content) > MAX_ARTICLE_CONTENT_CHARS: |
| 555 | content = content[:MAX_ARTICLE_CONTENT_CHARS] |
| 556 | return content, title, summary |
| 557 | |
| 558 | |
| 559 | def verify_article_merged( |
| 560 | article_path: str, |
| 561 | manifest: dict[str, Any], |
| 562 | *, |
| 563 | repo_root: Path = REPO_ROOT, |
| 564 | ) -> str: |
| 565 | """Fail closed unless the merged article matches the manifest checksum. |
| 566 | |
| 567 | Verifies the article file exists locally (i.e. the weekly article has been |
| 568 | merged to main) and that its sha256 matches the manifest |
| 569 | candidate.content_sha256. Returns the verified sha256. Raises |
| 570 | PodcasterHandoffError otherwise so the podcaster is never triggered for a |
| 571 | missing/stub article before the article merge is complete. |
| 572 | """ |
| 573 | candidate = manifest.get("candidate") if isinstance(manifest, dict) else None |
| 574 | expected = candidate.get("content_sha256") if isinstance(candidate, dict) else None |
| 575 | if not isinstance(expected, str) or not re.fullmatch(r"[0-9a-f]{64}", expected): |
| 576 | raise PodcasterHandoffError( |
| 577 | "Manifest lacks a valid candidate.content_sha256; cannot verify the merged article." |
| 578 | ) |
| 579 | resolved = (repo_root / article_path).resolve() |
| 580 | try: |
| 581 | resolved.relative_to(repo_root.resolve()) |
| 582 | except ValueError: |
| 583 | raise PodcasterHandoffError( |
| 584 | f"article_path resolves outside the repository root: {article_path}" |
| 585 | ) |
| 586 | if not resolved.is_file(): |
| 587 | raise PodcasterHandoffError( |
| 588 | f"Article not merged yet: {article_path} is not present. " |
| 589 | "Trigger the handoff only after the weekly article is merged to main." |
| 590 | ) |
| 591 | try: |
| 592 | actual = hashlib.sha256(resolved.read_bytes()).hexdigest() |
| 593 | except OSError as exc: |
| 594 | raise PodcasterHandoffError(f"Could not read merged article {article_path}: {exc}") from exc |
| 595 | if actual != expected: |
| 596 | raise PodcasterHandoffError( |
| 597 | f"Merged article sha256 mismatch for {article_path}: expected {expected}, got {actual}." |
| 598 | ) |
| 599 | return actual |
| 600 | |
| 601 | |
| 602 | def _manifest_allows_handoff(manifest: dict[str, Any], *, week: str, publish_mode: str) -> bool: |
| 603 | if not manifest: |
| 604 | return True |
| 605 | analysis = manifest.get("analysis") |
| 606 | promotion = manifest.get("promotion") |
| 607 | return ( |
| 608 | manifest.get("week") == week |
| 609 | and (manifest.get("run_mode") == "normal" or _is_audited_force_replace(manifest)) |
| 610 | and publish_mode == "normal" |
| 611 | and isinstance(analysis, dict) |
| 612 | and analysis.get("ai_status") == "ai" |
| 613 | and isinstance(promotion, dict) |
| 614 | and promotion.get("eligible") is True |
| 615 | and promotion.get("decision") == "promote" |
| 616 | ) |
| 617 | |
| 618 | |
| 619 | def _is_audited_force_replace(manifest: dict[str, Any]) -> bool: |
| 620 | promotion = manifest.get("promotion") |
| 621 | audit = manifest.get("audit") |
| 622 | return ( |
| 623 | isinstance(promotion, dict) |
| 624 | and promotion.get("policy") == "force-replace" |
| 625 | and isinstance(audit, dict) |
| 626 | and bool(audit.get("actor")) |
| 627 | and bool(audit.get("reason")) |
| 628 | ) |
| 629 | |
| 630 | |
| 631 | def _is_gated_replay(manifest: dict[str, Any], *, week: str) -> bool: |
| 632 | """Well-formed, promotion-eligible manifest that is deliberately excluded from |
| 633 | handoff (a plain, non-audited ``restore`` replay). Such a manifest is a clean |
| 634 | skip, not an error. Anything else -- malformed manifests, a missing/unknown |
| 635 | run_mode, or other non-normal modes -- returns False here and stays fail-closed |
| 636 | in build_payload, so a genuinely broken manifest is never silently skipped. |
| 637 | """ |
| 638 | if not manifest: |
| 639 | return False |
| 640 | analysis = manifest.get("analysis") |
| 641 | promotion = manifest.get("promotion") |
| 642 | well_formed_promotable = ( |
| 643 | manifest.get("week") == week |
| 644 | and isinstance(analysis, dict) |
| 645 | and analysis.get("ai_status") == "ai" |
| 646 | and isinstance(promotion, dict) |
| 647 | and promotion.get("eligible") is True |
| 648 | and promotion.get("decision") == "promote" |
| 649 | ) |
| 650 | gated_mode = manifest.get("run_mode") == "restore" and not _is_audited_force_replace(manifest) |
| 651 | return well_formed_promotable and gated_mode |
| 652 | |
| 653 | |
| 654 | def build_payload( |
| 655 | *, |
| 656 | week: str, |
| 657 | article_url: str, |
| 658 | article_path: str, |
| 659 | publish_run_id: str, |
| 660 | publish_mode: str = "normal", |
| 661 | manifest_path: Path | None = None, |
| 662 | podcast_config_path: Path | None = None, |
| 663 | podcaster_dry_run: bool = False, |
| 664 | repo_root: Path | None = None, |
| 665 | breaking_news: str | None = None, |
| 666 | require_merged: bool = False, |
| 667 | manifest: dict[str, Any] | None = None, |
| 668 | ) -> dict[str, Any]: |
| 669 | if manifest is None: |
| 670 | manifest = _load_manifest(manifest_path) |
| 671 | if not _manifest_allows_handoff(manifest, week=week, publish_mode=publish_mode): |
| 672 | raise PodcasterHandoffError("Publish manifest is not eligible for Podcaster handoff.") |
| 673 | normalized_path = normalize_page_path(article_path) |
| 674 | root = repo_root if repo_root is not None else REPO_ROOT |
| 675 | if require_merged: |
| 676 | verify_article_merged(normalized_path, manifest, repo_root=root) |
| 677 | payload: dict[str, Any] = { |
| 678 | "week": week, |
| 679 | "article_url": article_url, |
| 680 | "article_path": normalized_path, |
| 681 | "publish_run_id": publish_run_id, |
| 682 | "publish_mode": publish_mode, |
| 683 | } |
| 684 | |
| 685 | # Read article content and extract title |
| 686 | content, title, summary = _read_article_content(normalized_path, repo_root=root) |
| 687 | if content: |
| 688 | payload["article_content"] = content |
| 689 | if title: |
| 690 | payload["article_title"] = title |
| 691 | if summary: |
| 692 | payload["article_summary"] = summary |
| 693 | candidate = manifest.get("candidate") |
| 694 | article_sha = None |
| 695 | if isinstance(candidate, dict): |
| 696 | article_sha = candidate.get("content_sha256") or candidate.get("summary_sha256") |
| 697 | if ( |
| 698 | isinstance(article_sha, str) |
| 699 | and len(article_sha) == 64 |
| 700 | and article_sha.lower() == article_sha |
| 701 | ): |
| 702 | payload["article_sha256"] = article_sha |
| 703 | source_refs = _source_artifact_refs(manifest) |
| 704 | if source_refs: |
| 705 | payload["source_artifacts"] = source_refs |
| 706 | |
| 707 | podcast_cfg = _load_podcast_config(podcast_config_path) |
| 708 | if "podcast_config" in podcast_cfg: |
| 709 | val = podcast_cfg["podcast_config"] |
| 710 | if not isinstance(val, dict): |
| 711 | raise PodcasterHandoffError("podcast_config must be a JSON object") |
| 712 | payload["podcast_config"] = val |
| 713 | if "script_directions" in podcast_cfg: |
| 714 | val = podcast_cfg["script_directions"] |
| 715 | if not isinstance(val, dict): |
| 716 | raise PodcasterHandoffError("script_directions must be a JSON object") |
| 717 | payload["script_directions"] = val |
| 718 | historical_context = _read_historical_context(week, root) |
| 719 | if historical_context: |
| 720 | if "script_directions" not in payload: |
| 721 | payload["script_directions"] = {} |
| 722 | payload["script_directions"]["historical_context"] = historical_context |
| 723 | if "spotify_publish" in podcast_cfg: |
| 724 | val = podcast_cfg["spotify_publish"] |
| 725 | if not isinstance(val, dict): |
| 726 | raise PodcasterHandoffError("spotify_publish must be a JSON object") |
| 727 | payload["spotify_publish"] = _resolve_spotify_publish( |
| 728 | val, |
| 729 | week=week, |
| 730 | article_title=title, |
| 731 | article_summary=summary, |
| 732 | ) |
| 733 | |
| 734 | if breaking_news: |
| 735 | payload["breaking_news"] = breaking_news |
| 736 | |
| 737 | if podcaster_dry_run: |
| 738 | payload["dry_run"] = True |
| 739 | return payload |
| 740 | |
| 741 | |
| 742 | SUCCESS_RESPONSE_STATUSES = {"accepted", "dry_run"} |
| 743 | |
| 744 | |
| 745 | def validate_response(payload: Any) -> dict[str, Any]: |
| 746 | if not isinstance(payload, dict): |
| 747 | raise PodcasterHandoffError("Podcaster response must be a JSON object.") |
| 748 | status = payload.get("status") |
| 749 | errors = payload.get("errors", []) |
| 750 | if status not in SUCCESS_RESPONSE_STATUSES: |
| 751 | raise PodcasterHandoffError( |
| 752 | f"Podcaster response status was not a known success status " |
| 753 | f"(expected one of {sorted(SUCCESS_RESPONSE_STATUSES)}): {status!r}." |
| 754 | ) |
| 755 | if isinstance(errors, list) and errors: |
| 756 | raise PodcasterHandoffError("Podcaster response contained errors.") |
| 757 | if errors not in ([], None) and not isinstance(errors, list): |
| 758 | raise PodcasterHandoffError("Podcaster response errors field must be a list when present.") |
| 759 | if not isinstance(payload.get("job_id"), str) or not payload["job_id"].strip(): |
| 760 | raise PodcasterHandoffError("Podcaster response is missing job_id.") |
| 761 | return payload |
| 762 | |
| 763 | |
| 764 | def post_handoff( |
| 765 | endpoint: str, api_key: str, payload: dict[str, Any], *, timeout: int = DEFAULT_TIMEOUT_SECONDS |
| 766 | ) -> dict[str, Any]: |
| 767 | validate_endpoint(endpoint) |
| 768 | body = json.dumps(payload, separators=(",", ":")).encode("utf-8") |
| 769 | req = request.Request( |
| 770 | endpoint, |
| 771 | data=body, |
| 772 | method="POST", |
| 773 | headers={ |
| 774 | "Content-Type": "application/json", |
| 775 | AUTH_HEADER: api_key, |
| 776 | "User-Agent": "SquadScope-Podcaster-Handoff/1.0", |
| 777 | }, |
| 778 | ) |
| 779 | try: |
| 780 | with request.urlopen(req, timeout=timeout) as response: # nosec B310 |
| 781 | status_code = getattr(response, "status", response.getcode()) |
| 782 | response_body = response.read().decode("utf-8") |
| 783 | except error.HTTPError as exc: |
| 784 | try: |
| 785 | raw = exc.read(1024) |
| 786 | error_body = raw.decode("utf-8", errors="replace") |
| 787 | # Sanitize for GitHub Actions: strip workflow-command sequences and newlines |
| 788 | error_body = error_body.replace("::", "").replace("\r", " ").replace("\n", " ") |
| 789 | except Exception: |
| 790 | error_body = "<unreadable>" |
| 791 | raise PodcasterHandoffError( |
| 792 | f"Podcaster handoff failed with HTTP {exc.code}. Response body: {error_body}" |
| 793 | ) from exc |
| 794 | except error.URLError as exc: |
| 795 | raise PodcasterHandoffError(f"Podcaster handoff failed: {exc.reason}") from exc |
| 796 | |
| 797 | if status_code < 200 or status_code >= 300: |
| 798 | raise PodcasterHandoffError(f"Podcaster handoff failed with HTTP {status_code}.") |
| 799 | try: |
| 800 | response_payload = json.loads(response_body) |
| 801 | except json.JSONDecodeError as exc: |
| 802 | raise PodcasterHandoffError("Podcaster response was not valid JSON.") from exc |
| 803 | return validate_response(response_payload) |
| 804 | |
| 805 | |
| 806 | def main(argv: list[str] | None = None) -> int: |
| 807 | args = parse_args(argv) |
| 808 | endpoint = args.endpoint.strip() |
| 809 | api_key = os.environ.get("PODCASTER_API_KEY", "").strip() |
| 810 | if not endpoint or not api_key: |
| 811 | print( |
| 812 | "::notice::Podcaster handoff skipped because PODCASTER_ENDPOINT and PODCASTER_API_KEY are not both configured." |
| 813 | ) |
| 814 | return 0 |
| 815 | |
| 816 | if args.publish_mode != "normal": |
| 817 | print(f"::notice::Podcaster handoff skipped for publish mode {args.publish_mode}.") |
| 818 | return 0 |
| 819 | |
| 820 | try: |
| 821 | manifest = _load_manifest(args.manifest) |
| 822 | except PodcasterHandoffError as exc: |
| 823 | print(f"::error::Podcaster handoff failed: {exc}") |
| 824 | return 1 |
| 825 | if _is_gated_replay(manifest, week=args.week): |
| 826 | print( |
| 827 | "::notice::Podcaster handoff skipped: publish manifest is a non-audited replay " |
| 828 | "(not eligible for handoff)." |
| 829 | ) |
| 830 | return 0 |
| 831 | |
| 832 | try: |
| 833 | payload = build_payload( |
| 834 | week=args.week, |
| 835 | article_url=args.article_url, |
| 836 | article_path=args.article_path, |
| 837 | publish_run_id=args.publish_run_id, |
| 838 | publish_mode=args.publish_mode, |
| 839 | manifest_path=args.manifest, |
| 840 | podcast_config_path=args.podcast_config, |
| 841 | podcaster_dry_run=args.podcaster_dry_run, |
| 842 | breaking_news=args.breaking_news, |
| 843 | require_merged=args.require_merged, |
| 844 | manifest=manifest, |
| 845 | ) |
| 846 | response = post_handoff(endpoint, api_key, payload, timeout=args.timeout) |
| 847 | except PodcasterHandoffError as exc: |
| 848 | print(f"::error::Podcaster handoff failed: {exc}") |
| 849 | return 1 |
| 850 | job_id = _escape_gha_data(str(response.get("job_id", ""))) |
| 851 | status = _escape_gha_data(str(response.get("status", ""))) |
| 852 | print(f"::notice::Podcaster handoff completed (job_id={job_id}, status={status}).") |
| 853 | return 0 |
| 854 | |
| 855 | |
| 856 | if __name__ == "__main__": |
| 857 | raise SystemExit(main()) |