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()