feat: add Podcaster handoff integration (#314)

Operator-approved; all Copilot review threads resolved (manifest fail-closed, strict status validation, canonical summary_sha256, loopback msg). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed Jun 10, 2026 at 22:32 UTC 88bbb008b5996cb4c6d12a8e8fd2519b80c5fcf6
11 files changed +781 -4
.github/workflows/crawl-and-publish.yml
+56 -2
@@ -1119,13 +1119,67 @@ jobs:
1119 run: npx pagefind --site public/
1120
1121 - name: Upload Pages artifact
1122 - uses: actions/upload-pages-artifact@v3
1122 + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
1123 with:
1124 path: ./public
1125
1126 - name: Deploy to GitHub Pages
1127 id: deployment
1128 - uses: actions/deploy-pages@v4
1128 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
1129 +
1130 + podcaster-handoff:
1131 + needs: [analyze, generate, deploy]
1132 + if: ${{ needs.analyze.outputs.run_mode == 'normal' }}
1133 + runs-on: ubuntu-latest
1134 + continue-on-error: true
1135 + permissions:
1136 + contents: read
1137 +
1138 + steps:
1139 + - name: Check out repository
1140 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
1141 + with:
1142 + persist-credentials: false
1143 +
1144 + - name: Download analysis candidate
1145 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
1146 + with:
1147 + name: analysis-candidate
1148 + path: data/candidates/
1149 +
1150 + - name: Notify Podcaster
1151 + env:
1152 + PODCASTER_ENDPOINT: ${{ vars.PODCASTER_ENDPOINT }}
1153 + PODCASTER_API_KEY: ${{ secrets.PODCASTER_API_KEY }}
1154 + WEEK: ${{ needs.analyze.outputs.week }}
1155 + PAGE_PATH: ${{ needs.generate.outputs.page_path }}
1156 + MANIFEST_FILE: ${{ needs.analyze.outputs.publish_manifest_file }}
1157 + RUN_MODE: ${{ needs.analyze.outputs.run_mode }}
1158 + PUBLISH_RUN_ID: ${{ github.run_id }}
1159 + run: |
1160 + set -euo pipefail
1161 + ARTICLE_URL=$(python3 - <<'PY' "$PAGE_PATH"
1162 + import sys
1163 + from scripts.podcaster_handoff import article_url_from_page_path
1164 +
1165 + print(article_url_from_page_path("https://jmservera.github.io/SquadScope/", sys.argv[1]))
1166 + PY
1167 + )
1168 + if ! python3 scripts/publish_manifest.py assert-eligible --manifest "$MANIFEST_FILE"; then
1169 + echo "::warning::Podcaster handoff skipped because the publish manifest is not eligible."
1170 + exit 0
1171 + fi
1172 + ARGS=(
1173 + --week "$WEEK"
1174 + --article-url "$ARTICLE_URL"
1175 + --article-path "$PAGE_PATH"
1176 + --publish-run-id "$PUBLISH_RUN_ID"
1177 + --publish-mode "$RUN_MODE"
1178 + --manifest "$MANIFEST_FILE"
1179 + )
1180 + if ! python3 scripts/podcaster_handoff.py "${ARGS[@]}"; then
1181 + echo "::warning::Podcaster handoff failed or was rejected; weekly article publication remains complete."
1182 + fi
1183
1184 notify:
1185 if: ${{ (github.event_name == 'schedule' || github.event.inputs.publish_release == 'true') && needs.analyze.outputs.run_mode != 'dry-run' && needs.analyze.outputs.run_mode != 'candidate-only' }}
.github/workflows/podcaster-handoff-smoke.yml new
+91
@@ -0,0 +1,91 @@
1 +name: Podcaster handoff smoke test
2 +
3 +on:
4 + workflow_dispatch:
5 + inputs:
6 + week:
7 + description: 'Week slug to validate, e.g. 2026-W23.'
8 + required: true
9 + type: string
10 + article_url:
11 + description: 'Published SquadScope article URL to send to Podcaster.'
12 + required: true
13 + type: string
14 + article_path:
15 + description: 'Published article path, e.g. content/weekly/2026/W23.md.'
16 + required: true
17 + type: string
18 + article_sha256:
19 + description: 'Optional 64-character article SHA-256 to include in the dry-run payload.'
20 + required: false
21 + default: ''
22 + type: string
23 +
24 +permissions:
25 + contents: read
26 +
27 +jobs:
28 + smoke:
29 + runs-on: ubuntu-latest
30 + permissions:
31 + contents: read
32 +
33 + steps:
34 + - name: Check out repository
35 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
36 + with:
37 + persist-credentials: false
38 +
39 + - name: Set up Python
40 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
41 + with:
42 + python-version: '3.12'
43 +
44 + - name: Smoke test Podcaster dry run
45 + env:
46 + PODCASTER_ENDPOINT: ${{ vars.PODCASTER_ENDPOINT }}
47 + PODCASTER_API_KEY: ${{ secrets.PODCASTER_API_KEY }}
48 + WEEK: ${{ inputs.week }}
49 + ARTICLE_URL: ${{ inputs.article_url }}
50 + ARTICLE_PATH: ${{ inputs.article_path }}
51 + ARTICLE_SHA256: ${{ inputs.article_sha256 || '' }}
52 + PUBLISH_RUN_ID: ${{ github.run_id }}
53 + run: |
54 + set -euo pipefail
55 + if [ -z "$PODCASTER_ENDPOINT" ] || [ -z "$PODCASTER_API_KEY" ]; then
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
67 + import json
68 + import sys
69 + from pathlib import Path
70 +
71 + week, article_sha, manifest_path = sys.argv[1:]
72 + manifest = {
73 + "week": week,
74 + "run_mode": "normal",
75 + "candidate": {"summary_sha256": article_sha},
76 + "analysis": {"ai_status": "ai"},
77 + "promotion": {"eligible": True, "decision": "promote"},
78 + "source_artifacts": [],
79 + }
80 + Path(manifest_path).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
81 + PY
82 + MANIFEST_ARGS=(--manifest .podcaster-smoke/publish-manifest.json)
83 + fi
84 + python3 scripts/podcaster_handoff.py \
85 + --week "$WEEK" \
86 + --article-url "$ARTICLE_URL" \
87 + --article-path "$ARTICLE_PATH" \
88 + --publish-run-id "$PUBLISH_RUN_ID" \
89 + --publish-mode normal \
90 + --podcaster-dry-run \
91 + "${MANIFEST_ARGS[@]}"
.squad/agents/bender/history.md
+3
@@ -5,6 +5,9 @@
5 - Produces structured artifacts for analysis rather than editorial output.
6
7 ## Learnings
8 +- 2026-06-07T21:42:28.011+00:00: Issue #302 Podcaster handoff now belongs after successful normal weekly article deploy: `podcaster-handoff` depends on `analyze`, `generate`, and `deploy`, gates on `run_mode == 'normal'`, and stays non-blocking for article publication.
9 +- 2026-06-07T21:42:28.011+00:00: Podcaster payload contract is produced by `scripts/podcaster_handoff.py` and includes week, article URL/path, article hash when available, publish run ID, publish mode, and source artifact references while reading endpoint/key from Actions variable/secret without logging the key.
10 +- 2026-06-07T21:42:28.011+00:00: Ineligible modes for Podcaster are enforced both at workflow and manifest/script boundaries: dry-run, candidate-only, restore, force-replace, no-AI, and failed publish/deploy paths must not call Podcaster.
11 - Weekly crawl output should preserve both newly discovered repos and momentum candidates so downstream stages can reason about freshness and star gains.
12 - Star-gain estimates depend on comparing current search results against the most recent prior snapshot, so snapshot compatibility matters as much as the live crawl.
13 - Rate-limited integrations should follow the shared `exponential-backoff-with-jitter` skill instead of open-coding retry behavior.
.squad/agents/fry/history.md
+11
@@ -5,6 +5,8 @@
5 - Uses test coverage to keep workflow changes honest.
6
7 ## Learnings
8 +- 2026-06-07T21:42:28.011+00:00: Podcaster handoff QA passed local mock dry-run and full tests without exposing secrets; Actions has Podcaster endpoint/key configured by presence only, but the current workflow has no safe live Podcaster dry-run path because dry-run/candidate-only skip `podcaster-handoff` and normal/force-replace can mutate production content.
9 +- 2026-06-07T21:42:28.011+00:00: Issue #302 handoff readiness landed in PR #314: the `podcaster-handoff` job now declares `needs: [analyze, generate, deploy]` so it waits for the Pages deploy, the payload carries publish run ID (`--publish-run-id`) and publish mode (`--publish-mode`), and the job runs with `continue-on-error: true` so Podcaster errors no longer fail the weekly workflow. Remaining note: the handoff is still an in-workflow job rather than a separate post-success workflow.
10 - 2026-06-05T15:36:19.379+00:00: The weekly crawl pipeline needs a terminal data-only no-AI analysis fallback after Copilot and GitHub Models fail, because model-access errors such as `no_access` are real reliability bugs, not transient deploy noise.
11 - The PaperMod theme in this repo needs Hugo `v0.146.0+`, so build validation must use a sufficiently new Hugo binary.
12 - End-to-end checks matter more than isolated unit confidence when artifacts move across crawl, analyze, and publish stages.
@@ -123,3 +125,12 @@
125 - ✅ No regressions: pytest full pass (673 passed, 2 subtests)
126 - PR #288 validated and approved; ready for Coordinator merge workflow
127 - Orchestration log recorded at `.squad/orchestration-log/20260606T212350Z-fry.md`
128 +
129 +## Podcaster handoff QA validation (2026-06-07T21:42:28.011+00:00)
130 +
131 +- Validated SquadScope-side Podcaster handoff locally without reading or printing `PODCASTER_API_KEY`; local shell did not expose Podcaster endpoint/key, while Actions has both `PODCASTER_ENDPOINT` variable and `PODCASTER_API_KEY` secret configured by presence check only.
132 +- Commands: `TMPDIR=$PWD/.copilot/local-tmp python3 -m pytest tests/test_podcaster_handoff.py tests/test_pipeline.py -q` passed 20 tests; `TMPDIR=$PWD/.copilot/local-tmp python3 -m pytest -q` passed 702 tests plus 2 subtests.
133 +- Mock dry-run exercised `scripts/podcaster_handoff.py --podcaster-dry-run` against a localhost HTTP server with a placeholder key: response accepted, auth header present, stdout did not disclose the key, and payload included `week`, `article_url`, `article_sha256`, `source_artifacts`, and `dry_run: true`.
134 +- Workflow audit: `.github/workflows/crawl-and-publish.yml` only runs `podcaster-handoff` for non-`dry-run`/non-`candidate-only` runs, so `gh workflow run crawl-and-publish.yml -f run_mode=dry-run` is safe for the article pipeline but cannot validate the real Podcaster secret/endpoint handoff.
135 +- QA blocker for first live Podcaster dry-run: the configured secret is Actions-only and there is no dedicated non-publishing Podcaster dry-run workflow/job. Do not dispatch a normal run solely to test Podcaster because it can publish or replace production content.
136 +- Issue #302 acceptance gaps resolved in PR #314: the handoff job now declares `needs: [analyze, generate, deploy]` so it waits for `deploy`, passes publish run ID and publish mode in the payload, and runs with `continue-on-error: true` so a Podcaster failure cannot fail the weekly workflow. Remaining design note: it is still an in-workflow job rather than a separate post-success workflow.
.squad/skills/podcaster-handoff-validation/SKILL.md new
+23
@@ -0,0 +1,23 @@
1 +---
2 +name: podcaster-handoff-validation
3 +description: Validate Podcaster handoff without exposing secrets or publishing content
4 +domain: quality, pipeline-validation, secret-handling
5 +confidence: medium
6 +source: Fry Podcaster handoff QA validation
7 +---
8 +
9 +## Pattern
10 +
11 +- Gate post-publish handoffs on the exact eligible mode (`run_mode == "normal"`) and on successful upstream publish/deploy jobs; broad negative filters are easy to miss when new rerun modes are added.
12 +- Keep downstream handoff failures non-blocking and outside the article publication success criteria; warn and preserve the completed publish.
13 +- Validate the publish manifest immediately before handoff so no-AI, stale, failed, or unpromoted candidates cannot leak into downstream generation.
14 +- Validate client behavior locally with a localhost mock server and a placeholder API key.
15 +- Check secret availability by presence only; never print or retrieve secret values.
16 +- Use project-local scratch space via `TMPDIR=$PWD/.copilot/local-tmp` so existing tests that call `tempfile` do not write outside the repo.
17 +- Treat workflow dry-run support as valid only if the Podcaster job actually runs and sends `dry_run: true` without publishing or mutating production content.
18 +
19 +## Anti-patterns
20 +
21 +- Dispatching a normal publish workflow solely to test a downstream handoff.
22 +- Reading `.env` or printing configured secret values for validation.
23 +- Assuming a pipeline `dry-run` validates handoff when the handoff job is skipped by workflow conditions.
README.md
+1
@@ -119,6 +119,7 @@ JSON Markdown Hugo Pages Improvements
119
120 - `COPILOT_GH_TOKEN` — Fine-grained PAT with **Account → Copilot Requests** permission for Copilot CLI analysis
121 - `GITHUB_TOKEN` — Built-in; used for crawling, commits, Pages deployment, and issue/notification automation
122 +- `PODCASTER_API_KEY` — Optional; used with the `PODCASTER_ENDPOINT` Actions variable for the post-publish Podcaster handoff. The value must never be logged or committed.
123
124 ## Crawler notes
125
docs/operator-guide.md
+11 -2
@@ -94,14 +94,23 @@ Supported endpoints:
94
95 If `WEBHOOK_URL` is unset, the workflow skips the webhook step automatically.
96
97 -### Step 5: Enable GitHub Pages
97 +### Step 5: Configure optional Podcaster handoff
98 +
99 +To ask the separate Podcaster service to generate an episode after a normal weekly article is published and deployed, configure:
100 +
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.
105 +
106 +### Step 6: Enable GitHub Pages
107
108 1. Navigate to repo **Settings → Pages**
109 2. Set **Source** to "GitHub Actions"
110 3. (Optional) Configure custom domain if desired
111 4. Save
112
104 -### Step 6: Test local build
113 +### Step 7: Test local build
114
115 Ensure the Hugo build works locally:
116
docs/pipeline-validation.md
+2
@@ -15,6 +15,7 @@ Required secrets/tokens:
15
16 - `COPILOT_GH_TOKEN` — fine-grained PAT used as `COPILOT_GITHUB_TOKEN` for Copilot CLI analysis.
17 - `GITHUB_TOKEN` — built-in workflow token used for crawling, artifact downloads, commits, token-renewal issue creation, and Pages deployment.
18 +- Optional Podcaster handoff: Actions variable `PODCASTER_ENDPOINT` and Actions secret `PODCASTER_API_KEY`.
19
20 ## Stage-by-stage validation
21
@@ -116,6 +117,7 @@ Required secrets/tokens:
117 - `crawl` → later runs: `crawl-cache`
118 - `analyze` → `generate`: `analyzed-data`
119 - `generate` → `deploy`: `generated-content`
120 +- `generate` + `deploy` → `podcaster-handoff`: normal-mode generated page path plus candidate publish manifest after a successful Pages deploy; Podcaster failures are reported as warnings and do not block or roll back weekly article publication.
121 - `crawl` and `analyze` also feed `deploy` so the final build uses the same run's data artifacts
122
123 ## Manual validation flow
scripts/podcaster_handoff.py new
+246
@@ -0,0 +1,246 @@
1 +#!/usr/bin/env python3
2 +from __future__ import annotations
3 +
4 +import argparse
5 +import json
6 +import os
7 +from pathlib import Path
8 +from typing import Any
9 +from urllib import error, request
10 +from urllib.parse import urljoin, urlparse
11 +
12 +
13 +AUTH_HEADER = "x-podcaster-api-key"
14 +DEFAULT_TIMEOUT_SECONDS = 30
15 +
16 +
17 +class PodcasterHandoffError(RuntimeError):
18 + pass
19 +
20 +
21 +def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
22 + parser = argparse.ArgumentParser(description="Notify Podcaster after a SquadScope weekly article is published.")
23 + parser.add_argument("--week", required=True, help="ISO week slug, e.g. 2026-W23.")
24 + parser.add_argument("--article-url", required=True, help="Published SquadScope article URL.")
25 + parser.add_argument("--article-path", required=True, help="Published SquadScope article content path.")
26 + parser.add_argument("--publish-run-id", required=True, help="GitHub Actions run ID that published the article.")
27 + parser.add_argument("--publish-mode", default="normal", help="Publish mode; only normal is eligible for Podcaster handoff.")
28 + parser.add_argument("--manifest", type=Path, help="Optional publish manifest used for article hash/source artifact metadata.")
29 + parser.add_argument("--podcaster-dry-run", action="store_true", help="Ask Podcaster to validate without generating an episode; intended only for the manual smoke workflow.")
30 + parser.add_argument("--endpoint", default=os.environ.get("PODCASTER_ENDPOINT", ""))
31 + parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_SECONDS)
32 + return parser.parse_args(argv)
33 +
34 +
35 +WEEKLY_CONTENT_PREFIX = "content/weekly/"
36 +
37 +
38 +def normalize_page_path(page_path: str) -> str:
39 + """Reduce an absolute Actions page path to its repo-relative form.
40 +
41 + The generate job emits page_path as an absolute runner path on GitHub
42 + Actions (see scripts/generate_content.py); mirror the workflow's
43 + GITHUB_WORKSPACE normalization by reducing any absolute path to the
44 + repo-relative segment beginning at content/weekly/.
45 + """
46 + path = page_path.strip().replace("\\", "/")
47 + index = path.find(WEEKLY_CONTENT_PREFIX)
48 + if index != -1:
49 + path = path[index:]
50 + return path.lstrip("/")
51 +
52 +
53 +def article_url_from_page_path(base_url: str, page_path: str) -> str:
54 + base = base_url.rstrip("/") + "/"
55 + path = normalize_page_path(page_path)
56 + if not path.startswith(WEEKLY_CONTENT_PREFIX) or not path.endswith(".md"):
57 + raise PodcasterHandoffError(f"Cannot derive weekly article URL from page path: {page_path}")
58 + slug = path.removeprefix(WEEKLY_CONTENT_PREFIX).removesuffix(".md").lower()
59 + return urljoin(base, f"weekly/{slug}/")
60 +
61 +
62 +def validate_endpoint(endpoint: str) -> None:
63 + parsed = urlparse(endpoint)
64 + if parsed.scheme not in {"https", "http"} or not parsed.netloc:
65 + raise PodcasterHandoffError("PODCASTER_ENDPOINT must be an absolute HTTP(S) URL.")
66 + if parsed.scheme == "http" and parsed.hostname not in {"localhost", "127.0.0.1", "::1"}:
67 + raise PodcasterHandoffError(
68 + "PODCASTER_ENDPOINT may use HTTP only for localhost or loopback addresses (127.0.0.1, ::1)."
69 + )
70 +
71 +
72 +def _load_manifest(path: Path | None) -> dict[str, Any]:
73 + if path is None:
74 + return {}
75 + if not path.exists():
76 + raise PodcasterHandoffError(
77 + f"Publish manifest path was provided but does not exist: {path}"
78 + )
79 + try:
80 + payload = json.loads(path.read_text(encoding="utf-8"))
81 + except (OSError, json.JSONDecodeError) as exc:
82 + raise PodcasterHandoffError(f"Publish manifest could not be read: {path}") from exc
83 + if not isinstance(payload, dict):
84 + raise PodcasterHandoffError(f"Publish manifest must be a JSON object: {path}")
85 + return payload
86 +
87 +
88 +def _source_artifact_refs(manifest: dict[str, Any]) -> list[dict[str, str]]:
89 + refs: list[dict[str, str]] = []
90 + for artifact in manifest.get("source_artifacts", []):
91 + if not isinstance(artifact, dict):
92 + continue
93 + ref: dict[str, str] = {}
94 + for key in ("role", "path", "sha256", "generated_at"):
95 + value = artifact.get(key)
96 + if isinstance(value, str) and value:
97 + ref[key] = value
98 + freshness = artifact.get("freshness")
99 + if isinstance(freshness, dict) and isinstance(freshness.get("status"), str):
100 + ref["freshness_status"] = freshness["status"]
101 + for key in ("url", "artifact_url"):
102 + value = artifact.get(key)
103 + if isinstance(value, str) and value.startswith(("https://", "http://localhost:", "http://127.0.0.1:")):
104 + ref[key] = value
105 + if ref:
106 + refs.append(ref)
107 + return refs
108 +
109 +
110 +def _manifest_allows_handoff(manifest: dict[str, Any], *, week: str, publish_mode: str) -> bool:
111 + if not manifest:
112 + return True
113 + analysis = manifest.get("analysis")
114 + promotion = manifest.get("promotion")
115 + return (
116 + manifest.get("week") == week
117 + and manifest.get("run_mode") == "normal"
118 + and publish_mode == "normal"
119 + and isinstance(analysis, dict)
120 + and analysis.get("ai_status") == "ai"
121 + and isinstance(promotion, dict)
122 + and promotion.get("eligible") is True
123 + and promotion.get("decision") == "promote"
124 + )
125 +
126 +
127 +def build_payload(
128 + *,
129 + week: str,
130 + article_url: str,
131 + article_path: str,
132 + publish_run_id: str,
133 + publish_mode: str = "normal",
134 + manifest_path: Path | None = None,
135 + podcaster_dry_run: bool = False,
136 +) -> dict[str, Any]:
137 + manifest = _load_manifest(manifest_path)
138 + if not _manifest_allows_handoff(manifest, week=week, publish_mode=publish_mode):
139 + raise PodcasterHandoffError("Publish manifest is not eligible for Podcaster handoff.")
140 + payload: dict[str, Any] = {
141 + "week": week,
142 + "article_url": article_url,
143 + "article_path": normalize_page_path(article_path),
144 + "publish_run_id": publish_run_id,
145 + "publish_mode": publish_mode,
146 + }
147 + article_sha = (
148 + manifest.get("candidate", {}).get("summary_sha256")
149 + if isinstance(manifest.get("candidate"), dict)
150 + else None
151 + )
152 + if isinstance(article_sha, str) and len(article_sha) == 64 and article_sha.lower() == article_sha:
153 + payload["article_sha256"] = article_sha
154 + source_refs = _source_artifact_refs(manifest)
155 + if source_refs:
156 + payload["source_artifacts"] = source_refs
157 + if podcaster_dry_run:
158 + payload["dry_run"] = True
159 + return payload
160 +
161 +
162 +SUCCESS_RESPONSE_STATUSES = {"accepted"}
163 +
164 +
165 +def validate_response(payload: Any) -> dict[str, Any]:
166 + if not isinstance(payload, dict):
167 + raise PodcasterHandoffError("Podcaster response must be a JSON object.")
168 + status = payload.get("status")
169 + errors = payload.get("errors", [])
170 + if status not in SUCCESS_RESPONSE_STATUSES:
171 + raise PodcasterHandoffError(
172 + f"Podcaster response status was not a known success status "
173 + f"(expected one of {sorted(SUCCESS_RESPONSE_STATUSES)}): {status!r}."
174 + )
175 + if isinstance(errors, list) and errors:
176 + raise PodcasterHandoffError("Podcaster response contained errors.")
177 + if errors not in ([], None) and not isinstance(errors, list):
178 + raise PodcasterHandoffError("Podcaster response errors field must be a list when present.")
179 + if not isinstance(payload.get("job_id"), str) or not payload["job_id"].strip():
180 + raise PodcasterHandoffError("Podcaster response is missing job_id.")
181 + return payload
182 +
183 +
184 +def post_handoff(endpoint: str, api_key: str, payload: dict[str, Any], *, timeout: int = DEFAULT_TIMEOUT_SECONDS) -> dict[str, Any]:
185 + validate_endpoint(endpoint)
186 + body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
187 + req = request.Request(
188 + endpoint,
189 + data=body,
190 + method="POST",
191 + headers={
192 + "Content-Type": "application/json",
193 + AUTH_HEADER: api_key,
194 + "User-Agent": "SquadScope-Podcaster-Handoff/1.0",
195 + },
196 + )
197 + try:
198 + with request.urlopen(req, timeout=timeout) as response: # nosec B310
199 + status_code = getattr(response, "status", response.getcode())
200 + response_body = response.read().decode("utf-8")
201 + except error.HTTPError as exc:
202 + raise PodcasterHandoffError(f"Podcaster handoff failed with HTTP {exc.code}.") from exc
203 + except error.URLError as exc:
204 + raise PodcasterHandoffError(f"Podcaster handoff failed: {exc.reason}") from exc
205 +
206 + if status_code < 200 or status_code >= 300:
207 + raise PodcasterHandoffError(f"Podcaster handoff failed with HTTP {status_code}.")
208 + try:
209 + response_payload = json.loads(response_body)
210 + except json.JSONDecodeError as exc:
211 + raise PodcasterHandoffError("Podcaster response was not valid JSON.") from exc
212 + return validate_response(response_payload)
213 +
214 +
215 +def main(argv: list[str] | None = None) -> int:
216 + args = parse_args(argv)
217 + endpoint = args.endpoint.strip()
218 + api_key = os.environ.get("PODCASTER_API_KEY", "").strip()
219 + if not endpoint or not api_key:
220 + print("::notice::Podcaster handoff skipped because PODCASTER_ENDPOINT and PODCASTER_API_KEY are not both configured.")
221 + return 0
222 +
223 + if args.publish_mode != "normal":
224 + print(f"::notice::Podcaster handoff skipped for publish mode {args.publish_mode}.")
225 + return 0
226 +
227 + try:
228 + payload = build_payload(
229 + week=args.week,
230 + article_url=args.article_url,
231 + article_path=args.article_path,
232 + publish_run_id=args.publish_run_id,
233 + publish_mode=args.publish_mode,
234 + manifest_path=args.manifest,
235 + podcaster_dry_run=args.podcaster_dry_run,
236 + )
237 + post_handoff(endpoint, api_key, payload, timeout=args.timeout)
238 + except PodcasterHandoffError as exc:
239 + print(f"::warning::{exc}")
240 + return 1
241 + print("::notice::Podcaster handoff accepted.")
242 + return 0
243 +
244 +
245 +if __name__ == "__main__":
246 + raise SystemExit(main())
tests/test_pipeline.py
+45
@@ -410,6 +410,51 @@ class WorkflowConfigTests(unittest.TestCase):
410 self.assertIn("📊 **SquadScope Week", webhook_run)
411 self.assertIn("Webhook post failed (non-critical)", webhook_run)
412
413 + def test_podcaster_handoff_runs_only_after_normal_deploy_without_blocking_deploy(self) -> None:
414 + workflow_path = Path(".github/workflows/crawl-and-publish.yml")
415 + workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))
416 +
417 + podcaster_job = workflow["jobs"]["podcaster-handoff"]
418 + self.assertEqual(podcaster_job["needs"], ["analyze", "generate", "deploy"])
419 + checkout_step = next((s for s in podcaster_job["steps"] if s.get("name") == "Check out repository"), None)
420 + self.assertEqual(
421 + checkout_step["uses"],
422 + "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd",
423 + )
424 + self.assertFalse(checkout_step["with"]["persist-credentials"])
425 + download_step = next((s for s in podcaster_job["steps"] if s.get("name") == "Download analysis candidate"), None)
426 + self.assertEqual(
427 + download_step["uses"],
428 + "actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093",
429 + )
430 + self.assertEqual(podcaster_job["if"], "${{ needs.analyze.outputs.run_mode == 'normal' }}")
431 + self.assertTrue(podcaster_job["continue-on-error"])
432 + self.assertNotIn("force-replace", podcaster_job["if"])
433 + self.assertNotIn("restore", podcaster_job["if"])
434 +
435 + deploy_job = workflow["jobs"]["deploy"]
436 + self.assertEqual(deploy_job["needs"], ["crawl", "analyze", "generate"])
437 + self.assertNotIn("podcaster-handoff", deploy_job["needs"])
438 +
439 + notify_step = next((s for s in podcaster_job["steps"] if s.get("name") == "Notify Podcaster"), None)
440 + self.assertIsNotNone(notify_step)
441 + self.assertEqual(notify_step["env"]["PODCASTER_ENDPOINT"], "${{ vars.PODCASTER_ENDPOINT }}")
442 + self.assertEqual(notify_step["env"]["PODCASTER_API_KEY"], "${{ secrets.PODCASTER_API_KEY }}")
443 + run_script = notify_step["run"]
444 + self.assertIn("article_url_from_page_path", run_script)
445 + self.assertIn("scripts/publish_manifest.py assert-eligible", run_script)
446 + self.assertIn("publish manifest is not eligible", run_script)
447 + self.assertIn("scripts/podcaster_handoff.py", run_script)
448 + self.assertIn('--article-path "$PAGE_PATH"', run_script)
449 + self.assertIn('--publish-run-id "$PUBLISH_RUN_ID"', run_script)
450 + self.assertIn('--publish-mode "$RUN_MODE"', run_script)
451 + self.assertIn('--manifest "$MANIFEST_FILE"', run_script)
452 + self.assertIn("weekly article publication remains complete", run_script)
453 + self.assertNotIn("--force", run_script)
454 + self.assertNotIn("--dry-run", run_script)
455 + self.assertNotIn("echo $PODCASTER_API_KEY", run_script)
456 + self.assertNotIn("curl", run_script)
457 +
458 def test_publish_workflow_uses_candidate_manifest_before_promotion(self) -> None:
459 workflow_path = Path(".github/workflows/crawl-and-publish.yml")
460 workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))
tests/test_podcaster_handoff.py new
+292
@@ -0,0 +1,292 @@
1 +import io
2 +import json
3 +import tempfile
4 +import unittest
5 +from pathlib import Path
6 +from unittest import mock
7 +from urllib import error
8 +
9 +import scripts.podcaster_handoff as podcaster_handoff
10 +
11 +
12 +class _FakeHTTPResponse(io.BytesIO):
13 + status = 202
14 +
15 + def __enter__(self):
16 + return self
17 +
18 + def __exit__(self, exc_type, exc, tb):
19 + self.close()
20 + return False
21 +
22 + def getcode(self):
23 + return self.status
24 +
25 +
26 +class PodcasterHandoffTests(unittest.TestCase):
27 + def _write_manifest(self, base: Path, *, run_mode: str = "normal", ai_status: str = "ai") -> Path:
28 + manifest = base / "publish-manifest.json"
29 + manifest.write_text(
30 + json.dumps(
31 + {
32 + "week": "2026-W23",
33 + "run_id": "123456789",
34 + "run_mode": run_mode,
35 + "candidate": {"summary_sha256": "a" * 64},
36 + "analysis": {"ai_status": ai_status},
37 + "promotion": {"eligible": True, "decision": "promote"},
38 + "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"},
41 + ],
42 + }
43 + ),
44 + encoding="utf-8",
45 + )
46 + return manifest
47 +
48 + def test_build_payload_uses_required_fields_and_real_optional_values(self) -> None:
49 + tests_root = Path(__file__).resolve().parent
50 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
51 + manifest = self._write_manifest(Path(tmpdir))
52 +
53 + payload = podcaster_handoff.build_payload(
54 + week="2026-W23",
55 + article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
56 + article_path="content/weekly/2026/W23.md",
57 + publish_run_id="123456789",
58 + publish_mode="normal",
59 + manifest_path=manifest,
60 + )
61 +
62 + self.assertEqual(payload["week"], "2026-W23")
63 + self.assertEqual(payload["article_url"], "https://jmservera.github.io/SquadScope/weekly/2026/w23/")
64 + self.assertEqual(payload["article_path"], "content/weekly/2026/W23.md")
65 + self.assertEqual(payload["publish_run_id"], "123456789")
66 + self.assertEqual(payload["publish_mode"], "normal")
67 + self.assertEqual(payload["article_sha256"], "a" * 64)
68 + self.assertEqual(
69 + payload["source_artifacts"],
70 + [
71 + {"role": "raw", "path": "data/raw/2026-W23.json", "sha256": "b" * 64},
72 + {"role": "blob", "url": "https://example.blob.core.windows.net/artifacts/source.json"},
73 + ],
74 + )
75 + self.assertNotIn("force", payload)
76 + self.assertNotIn("dry_run", payload)
77 +
78 + def test_podcaster_dry_run_sets_payload_flag(self) -> None:
79 + payload = podcaster_handoff.build_payload(
80 + week="2026-W23",
81 + article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
82 + article_path="content/weekly/2026/W23.md",
83 + publish_run_id="123456789",
84 + publish_mode="normal",
85 + podcaster_dry_run=True,
86 + )
87 +
88 + self.assertTrue(payload["dry_run"])
89 + self.assertEqual(payload["publish_mode"], "normal")
90 +
91 + def test_build_payload_normalizes_absolute_article_path(self) -> None:
92 + payload = podcaster_handoff.build_payload(
93 + week="2026-W23",
94 + article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
95 + article_path="/home/runner/work/SquadScope/SquadScope/content/weekly/2026/W23.md",
96 + publish_run_id="123456789",
97 + publish_mode="normal",
98 + )
99 +
100 + self.assertEqual(payload["article_path"], "content/weekly/2026/W23.md")
101 +
102 + def test_missing_config_skips_without_calling_podcaster(self) -> None:
103 + with mock.patch.object(podcaster_handoff.request, "urlopen") as urlopen_mock, mock.patch.dict(
104 + podcaster_handoff.os.environ, {"PODCASTER_API_KEY": ""}
105 + ), mock.patch("sys.stdout", new_callable=io.StringIO) as stdout:
106 + exit_code = podcaster_handoff.main(
107 + [
108 + "--week",
109 + "2026-W23",
110 + "--article-url",
111 + "https://jmservera.github.io/SquadScope/weekly/2026/w23/",
112 + "--article-path",
113 + "content/weekly/2026/W23.md",
114 + "--publish-run-id",
115 + "123456789",
116 + "--endpoint",
117 + "",
118 + ]
119 + )
120 +
121 + self.assertEqual(exit_code, 0)
122 + urlopen_mock.assert_not_called()
123 + self.assertIn("Podcaster handoff skipped", stdout.getvalue())
124 +
125 + def test_post_handoff_sends_auth_header_without_logging_value(self) -> None:
126 + response = _FakeHTTPResponse(json.dumps({"job_id": "podcast-2026-W23-abc12345", "status": "accepted", "errors": []}).encode())
127 + with mock.patch.object(podcaster_handoff.request, "urlopen", return_value=response) as urlopen_mock, mock.patch.dict(
128 + podcaster_handoff.os.environ, {"PODCASTER_API_KEY": "super-secret-value"}
129 + ), mock.patch("sys.stdout", new_callable=io.StringIO) as stdout:
130 + exit_code = podcaster_handoff.main(
131 + [
132 + "--week",
133 + "2026-W23",
134 + "--article-url",
135 + "https://jmservera.github.io/SquadScope/weekly/2026/w23/",
136 + "--article-path",
137 + "content/weekly/2026/W23.md",
138 + "--publish-run-id",
139 + "123456789",
140 + "--endpoint",
141 + "http://localhost:7071/api/generate",
142 + ]
143 + )
144 +
145 + self.assertEqual(exit_code, 0)
146 + req = urlopen_mock.call_args.args[0]
147 + self.assertEqual(req.get_header("X-podcaster-api-key"), "super-secret-value")
148 + self.assertEqual(req.get_header("Content-type"), "application/json")
149 + sent_payload = json.loads(req.data.decode("utf-8"))
150 + self.assertEqual(
151 + sent_payload,
152 + {
153 + "week": "2026-W23",
154 + "article_url": "https://jmservera.github.io/SquadScope/weekly/2026/w23/",
155 + "article_path": "content/weekly/2026/W23.md",
156 + "publish_run_id": "123456789",
157 + "publish_mode": "normal",
158 + },
159 + )
160 + self.assertNotIn("super-secret-value", stdout.getvalue())
161 +
162 + def test_non_normal_publish_mode_skips_without_calling_podcaster(self) -> None:
163 + with mock.patch.object(podcaster_handoff.request, "urlopen") as urlopen_mock, mock.patch.dict(
164 + podcaster_handoff.os.environ, {"PODCASTER_API_KEY": "super-secret-value"}
165 + ), mock.patch("sys.stdout", new_callable=io.StringIO) as stdout:
166 + exit_code = podcaster_handoff.main(
167 + [
168 + "--week",
169 + "2026-W23",
170 + "--article-url",
171 + "https://jmservera.github.io/SquadScope/weekly/2026/w23/",
172 + "--article-path",
173 + "content/weekly/2026/W23.md",
174 + "--publish-run-id",
175 + "123456789",
176 + "--publish-mode",
177 + "restore",
178 + "--endpoint",
179 + "http://localhost:7071/api/generate",
180 + ]
181 + )
182 +
183 + self.assertEqual(exit_code, 0)
184 + urlopen_mock.assert_not_called()
185 + self.assertIn("skipped for publish mode restore", stdout.getvalue())
186 + self.assertNotIn("super-secret-value", stdout.getvalue())
187 +
188 + def test_manifest_blocks_restore_and_no_ai_handoffs(self) -> None:
189 + tests_root = Path(__file__).resolve().parent
190 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
191 + base = Path(tmpdir)
192 + restore_manifest = self._write_manifest(base, run_mode="restore")
193 + with self.assertRaisesRegex(podcaster_handoff.PodcasterHandoffError, "not eligible"):
194 + podcaster_handoff.build_payload(
195 + week="2026-W23",
196 + article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
197 + article_path="content/weekly/2026/W23.md",
198 + publish_run_id="123456789",
199 + publish_mode="normal",
200 + manifest_path=restore_manifest,
201 + )
202 + no_ai_manifest = self._write_manifest(base, ai_status="no-ai")
203 + with self.assertRaisesRegex(podcaster_handoff.PodcasterHandoffError, "not eligible"):
204 + podcaster_handoff.build_payload(
205 + week="2026-W23",
206 + article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
207 + article_path="content/weekly/2026/W23.md",
208 + publish_run_id="123456789",
209 + publish_mode="normal",
210 + manifest_path=no_ai_manifest,
211 + )
212 +
213 + def test_missing_manifest_path_raises_fail_closed(self) -> None:
214 + tests_root = Path(__file__).resolve().parent
215 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
216 + missing = Path(tmpdir) / "does-not-exist.json"
217 + with self.assertRaisesRegex(podcaster_handoff.PodcasterHandoffError, "does not exist"):
218 + podcaster_handoff.build_payload(
219 + week="2026-W23",
220 + article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
221 + article_path="content/weekly/2026/W23.md",
222 + publish_run_id="123456789",
223 + publish_mode="normal",
224 + manifest_path=missing,
225 + )
226 +
227 + def test_validate_response_rejects_failed_status_or_errors(self) -> None:
228 + with self.assertRaises(podcaster_handoff.PodcasterHandoffError):
229 + podcaster_handoff.validate_response({"job_id": "podcast-1", "status": "failed", "errors": []})
230 + with self.assertRaises(podcaster_handoff.PodcasterHandoffError):
231 + podcaster_handoff.validate_response({"job_id": "podcast-1", "status": "accepted", "errors": ["bad"]})
232 +
233 + def test_validate_response_rejects_unexpected_or_missing_status(self) -> None:
234 + for status in ("rejected", "queued", "pending", None):
235 + with self.assertRaisesRegex(
236 + podcaster_handoff.PodcasterHandoffError, "known success status"
237 + ):
238 + podcaster_handoff.validate_response({"job_id": "podcast-1", "status": status, "errors": []})
239 + with self.assertRaisesRegex(
240 + podcaster_handoff.PodcasterHandoffError, "known success status"
241 + ):
242 + podcaster_handoff.validate_response({"job_id": "podcast-1", "errors": []})
243 +
244 + def test_validate_response_accepts_known_success_status(self) -> None:
245 + result = podcaster_handoff.validate_response(
246 + {"job_id": "podcast-1", "status": "accepted", "errors": []}
247 + )
248 + self.assertEqual(result["status"], "accepted")
249 +
250 + def test_non_2xx_response_fails_handoff(self) -> None:
251 + http_err = error.HTTPError(
252 + url="http://localhost:7071/api/generate",
253 + code=500,
254 + msg="Internal Server Error",
255 + hdrs={},
256 + fp=io.BytesIO(b'{"errors":["boom"]}'),
257 + )
258 + with mock.patch.object(podcaster_handoff.request, "urlopen", side_effect=http_err):
259 + with self.assertRaisesRegex(podcaster_handoff.PodcasterHandoffError, "HTTP 500"):
260 + podcaster_handoff.post_handoff(
261 + "http://localhost:7071/api/generate",
262 + "super-secret-value",
263 + {
264 + "week": "2026-W23",
265 + "article_url": "https://jmservera.github.io/SquadScope/weekly/2026/w23/",
266 + "article_path": "content/weekly/2026/W23.md",
267 + "publish_run_id": "123456789",
268 + "publish_mode": "normal",
269 + },
270 + )
271 +
272 + def test_article_url_from_page_path_matches_hugo_weekly_permalink(self) -> None:
273 + self.assertEqual(
274 + podcaster_handoff.article_url_from_page_path(
275 + "https://jmservera.github.io/SquadScope/",
276 + "content/weekly/2026/W23.md",
277 + ),
278 + "https://jmservera.github.io/SquadScope/weekly/2026/w23/",
279 + )
280 +
281 + def test_article_url_from_page_path_normalizes_absolute_runner_path(self) -> None:
282 + self.assertEqual(
283 + podcaster_handoff.article_url_from_page_path(
284 + "https://jmservera.github.io/SquadScope/",
285 + "/home/runner/work/SquadScope/SquadScope/content/weekly/2026/W23.md",
286 + ),
287 + "https://jmservera.github.io/SquadScope/weekly/2026/w23/",
288 + )
289 +
290 +
291 +if __name__ == "__main__":
292 + unittest.main()