| 1 | name: Podcaster handoff smoke test |
| 2 | |
| 3 | on: |
| 4 | workflow_dispatch: |
| 5 | # checkov:skip=CKV_GHA_7:Manual smoke-test workflow (not a release build). Inputs identify the article/week to validate the handoff payload; they do not produce or alter published build artifacts. Inputs are required to target a specific article. |
| 6 | inputs: |
| 7 | week: |
| 8 | description: 'Week slug to validate, e.g. 2026-W23.' |
| 9 | required: true |
| 10 | type: string |
| 11 | article_url: |
| 12 | description: 'Published SquadScope article URL to send to Podcaster.' |
| 13 | required: true |
| 14 | type: string |
| 15 | article_path: |
| 16 | description: 'Published article path, e.g. content/weekly/2026/W23.md.' |
| 17 | required: true |
| 18 | type: string |
| 19 | article_sha256: |
| 20 | description: 'Optional 64-character article SHA-256 override; when omitted the checked-out article file is hashed.' |
| 21 | required: false |
| 22 | default: '' |
| 23 | type: string |
| 24 | |
| 25 | permissions: |
| 26 | contents: read |
| 27 | |
| 28 | concurrency: |
| 29 | group: ${{ github.workflow }}-${{ github.ref }} |
| 30 | cancel-in-progress: false |
| 31 | |
| 32 | jobs: |
| 33 | smoke: |
| 34 | runs-on: ubuntu-latest |
| 35 | permissions: |
| 36 | contents: read |
| 37 | |
| 38 | steps: |
| 39 | - name: Check out repository |
| 40 | uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 |
| 41 | with: |
| 42 | persist-credentials: false |
| 43 | |
| 44 | - name: Set up Python |
| 45 | uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 |
| 46 | with: |
| 47 | python-version: '3.12' |
| 48 | |
| 49 | - name: Smoke test Podcaster dry run |
| 50 | env: |
| 51 | PODCASTER_ENDPOINT: ${{ vars.PODCASTER_ENDPOINT }} |
| 52 | PODCASTER_API_KEY: ${{ secrets.PODCASTER_API_KEY }} |
| 53 | WEEK: ${{ inputs.week }} |
| 54 | ARTICLE_URL: ${{ inputs.article_url }} |
| 55 | ARTICLE_PATH: ${{ inputs.article_path }} |
| 56 | ARTICLE_SHA256: ${{ inputs.article_sha256 || '' }} |
| 57 | PUBLISH_RUN_ID: ${{ github.run_id }} |
| 58 | run: | |
| 59 | set -euo pipefail |
| 60 | if [ -z "$PODCASTER_ENDPOINT" ] || [ -z "$PODCASTER_API_KEY" ]; then |
| 61 | echo "::error::Podcaster smoke test requires PODCASTER_ENDPOINT variable and PODCASTER_API_KEY secret." |
| 62 | exit 1 |
| 63 | fi |
| 64 | if [ ! -f "$ARTICLE_PATH" ]; then |
| 65 | echo "::error::article_path must exist in the checked-out repository so the smoke test exercises the real article-content handoff path." |
| 66 | exit 1 |
| 67 | fi |
| 68 | mkdir -p .podcaster-smoke |
| 69 | python3 - <<'PY' "$WEEK" "$ARTICLE_PATH" "$ARTICLE_SHA256" .podcaster-smoke/publish-manifest.json |
| 70 | import hashlib |
| 71 | import json |
| 72 | import sys |
| 73 | from pathlib import Path |
| 74 | |
| 75 | week, article_path, article_sha, manifest_path = sys.argv[1:] |
| 76 | article = Path(article_path) |
| 77 | if not article.is_file(): |
| 78 | raise SystemExit(f"article_path does not exist: {article_path}") |
| 79 | article_bytes = article.read_bytes() |
| 80 | actual_article_sha = hashlib.sha256(article_bytes).hexdigest() |
| 81 | if article_sha: |
| 82 | if len(article_sha) != 64 or article_sha.lower() != article_sha or any(ch not in "0123456789abcdef" for ch in article_sha): |
| 83 | raise SystemExit("article_sha256 must be lowercase 64-character hex when provided.") |
| 84 | if article_sha != actual_article_sha: |
| 85 | raise SystemExit("article_sha256 must match ARTICLE_PATH contents when provided.") |
| 86 | else: |
| 87 | article_sha = actual_article_sha |
| 88 | raw_payload = {"week": week, "source": "github", "article_path": article_path} |
| 89 | raw_bytes = (json.dumps(raw_payload, sort_keys=True) + "\n").encode("utf-8") |
| 90 | raw_sha = hashlib.sha256(raw_bytes).hexdigest() |
| 91 | article_size = len(article_bytes) |
| 92 | |
| 93 | def digest(label: str) -> str: |
| 94 | return hashlib.sha256(f"{week}:{label}".encode("utf-8")).hexdigest() |
| 95 | |
| 96 | manifest = { |
| 97 | "week": week, |
| 98 | "run_mode": "normal", |
| 99 | "candidate": {"summary_sha256": article_sha}, |
| 100 | "analysis": {"ai_status": "ai"}, |
| 101 | "promotion": {"eligible": True, "decision": "promote"}, |
| 102 | "source_artifacts": [ |
| 103 | { |
| 104 | "role": "raw_github", |
| 105 | "path": f"data/raw/{week}.json", |
| 106 | "name": f"{week}-raw-github", |
| 107 | "sha256": raw_sha, |
| 108 | "generated_at": "2026-06-08T10:15:00Z", |
| 109 | "crawled_at": "2026-06-08T10:12:00Z", |
| 110 | "source_status": "fresh", |
| 111 | "exists": True, |
| 112 | "size_bytes": len(raw_bytes), |
| 113 | "freshness": {"status": "fresh", "reasons": []}, |
| 114 | "provenance": { |
| 115 | "path": f"data/raw/{week}.json", |
| 116 | "sha256": raw_sha, |
| 117 | }, |
| 118 | "same_day_reuse": {"status": "reused", "source": "smoke"}, |
| 119 | "sources_requested": ["github"], |
| 120 | "sources_succeeded": ["github"], |
| 121 | "sources_failed": [], |
| 122 | }, |
| 123 | { |
| 124 | "role": "published_summary", |
| 125 | "path": article_path, |
| 126 | "href": f"https://example.com/{week}/source-index.json", |
| 127 | "sha256": article_sha, |
| 128 | "generated_at": "2026-06-08T11:20:00Z", |
| 129 | "exists": True, |
| 130 | "size_bytes": article_size, |
| 131 | "provenance": { |
| 132 | "path": article_path, |
| 133 | "sha256": article_sha, |
| 134 | }, |
| 135 | "source_reuse_summary": {"reused": False}, |
| 136 | }, |
| 137 | { |
| 138 | "role": "operator_packet", |
| 139 | "name": "weekly-publishing-packet", |
| 140 | "uri": f"https://example.com/{week}/publishing-packet.json", |
| 141 | "artifact_checksum": digest("publishing-packet"), |
| 142 | "schema_checksum": digest("publishing-packet-schema"), |
| 143 | "source_config_checksum": digest("publishing-packet-config"), |
| 144 | "source_artifact_provenance": {"source": "smoke-manifest"}, |
| 145 | "week": week, |
| 146 | }, |
| 147 | ], |
| 148 | } |
| 149 | Path(manifest_path).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") |
| 150 | PY |
| 151 | python3 - <<'PY' "$WEEK" "$ARTICLE_URL" "$ARTICLE_PATH" "$PUBLISH_RUN_ID" .podcaster-smoke/publish-manifest.json |
| 152 | import sys |
| 153 | from pathlib import Path |
| 154 | |
| 155 | from scripts.podcaster_handoff import build_payload |
| 156 | |
| 157 | week, article_url, article_path, publish_run_id, manifest_path = sys.argv[1:] |
| 158 | payload = build_payload( |
| 159 | week=week, |
| 160 | article_url=article_url, |
| 161 | article_path=article_path, |
| 162 | publish_run_id=publish_run_id, |
| 163 | publish_mode="normal", |
| 164 | manifest_path=Path(manifest_path), |
| 165 | podcast_config_path=Path("config/podcast.json"), |
| 166 | podcaster_dry_run=True, |
| 167 | ) |
| 168 | required_fields = ( |
| 169 | "source_artifacts", |
| 170 | "podcast_config", |
| 171 | "script_directions", |
| 172 | "spotify_publish", |
| 173 | "article_content", |
| 174 | "article_title", |
| 175 | "dry_run", |
| 176 | ) |
| 177 | missing = [field for field in required_fields if field not in payload] |
| 178 | if missing: |
| 179 | raise SystemExit(f"smoke payload is missing expected real-handoff fields: {', '.join(missing)}") |
| 180 | if not payload["source_artifacts"]: |
| 181 | raise SystemExit("smoke payload must include representative source_artifacts") |
| 182 | PY |
| 183 | python3 scripts/podcaster_handoff.py \ |
| 184 | --week "$WEEK" \ |
| 185 | --article-url "$ARTICLE_URL" \ |
| 186 | --article-path "$ARTICLE_PATH" \ |
| 187 | --publish-run-id "$PUBLISH_RUN_ID" \ |
| 188 | --publish-mode normal \ |
| 189 | --manifest .podcaster-smoke/publish-manifest.json \ |
| 190 | --podcast-config config/podcast.json \ |
| 191 | --podcaster-dry-run |