fix: podcaster handoff fires only after weekly article merged to main (#559)

* fix: trigger podcaster only after weekly article merged + sha256 verified Move the Podcaster handoff out of crawl-and-publish (which deploys from artifacts before the sync-to-main merge) into sync-publish-to-main, firing only after the weekly article is merged. Add --require-merged to podcaster_handoff.py: fail closed unless the merged article exists and its sha256 matches manifest candidate.content_sha256. Fixes the W27 stub race. Closes #558 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * review: derive article URL via helper, strict hex sha256, rename test, add hex test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: root-cause sync auto-merge + harden post-merge handoff guards Sync PR (#557) stayed open because the sync job died under set -e when 'gh pr checks' exited non-zero ('no checks reported') before checks registered, so the weekly article never reached main pre-handoff. - sync-publish-to-main: tolerate empty checks while polling so auto-merge proceeds; surface publish checkout/manifest issues as ::warning::. - podcaster_handoff: wrap read_bytes in OSError->PodcasterHandoffError. - tests: manifest fixture carries candidate.content_sha256 so unmerged test fails on missing article, not missing sha. Remove .fix-brief.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * style: ruff format podcaster_handoff OSError handler Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: harden publish checkout and surface gh pr checks errors - Skip Podcaster handoff (fail closed) and clear stale candidates if publish checkout fails - Stop discarding gh pr checks stderr so auth/network errors are visible Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed Jun 29, 2026 at 11:30 UTC 5ac57a70087539a79a6a1b843e911aca0b36416f
6 files changed +229 -95
.github/workflows/crawl-and-publish.yml
+5 -49
@@ -1223,55 +1223,11 @@ jobs:
1223 id: deployment
1224 uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
1225
1226 - podcaster-handoff:
1227 - needs: [analyze, generate, deploy]
1228 - if: ${{ needs.analyze.outputs.run_mode == 'normal' }}
1229 - runs-on: ubuntu-latest
1230 - permissions:
1231 - contents: read
1232 -
1233 - steps:
1234 - - name: Check out repository
1235 - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
1236 - with:
1237 - persist-credentials: false
1238 -
1239 - - name: Download analysis candidate
1240 - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
1241 - with:
1242 - name: analysis-candidate
1243 - path: data/candidates/
1244 -
1245 - - name: Notify Podcaster
1246 - env:
1247 - PODCASTER_ENDPOINT: ${{ vars.PODCASTER_ENDPOINT }}
1248 - PODCASTER_API_KEY: ${{ secrets.PODCASTER_API_KEY }}
1249 - WEEK: ${{ needs.analyze.outputs.week }}
1250 - PAGE_PATH: ${{ needs.generate.outputs.page_path }}
1251 - MANIFEST_FILE: ${{ needs.analyze.outputs.publish_manifest_file }}
1252 - RUN_MODE: ${{ needs.analyze.outputs.run_mode }}
1253 - PUBLISH_RUN_ID: ${{ github.run_id }}
1254 - run: |
1255 - set -euo pipefail
1256 - ARTICLE_URL=$(python3 - <<'PY' "$PAGE_PATH"
1257 - import sys
1258 - from scripts.podcaster_handoff import article_url_from_page_path
1259 -
1260 - print(article_url_from_page_path("https://jmservera.github.io/SquadScope/", sys.argv[1]))
1261 - PY
1262 - )
1263 - if ! python3 scripts/publish_manifest.py assert-eligible --manifest "$MANIFEST_FILE"; then
1264 - echo "::notice::Podcaster handoff skipped — publish manifest is not eligible."
1265 - exit 0
1266 - fi
1267 - python3 scripts/podcaster_handoff.py \
1268 - --week "$WEEK" \
1269 - --article-url "$ARTICLE_URL" \
1270 - --article-path "$PAGE_PATH" \
1271 - --publish-run-id "$PUBLISH_RUN_ID" \
1272 - --publish-mode "$RUN_MODE" \
1273 - --manifest "$MANIFEST_FILE" \
1274 - --podcast-config config/podcast.json
1226 + # NOTE: The Podcaster handoff intentionally lives in sync-publish-to-main.yml,
1227 + # which fires only AFTER the weekly article is merged into main. Triggering it
1228 + # here (off deploy) created a race where the podcaster could start before the
1229 + # article was merged, yielding stub episodes (e.g. W27). Do not re-add a
1230 + # deploy-coupled handoff job.
1231
1232 notify:
1233 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/sync-publish-to-main.yml
+81 -1
@@ -30,6 +30,7 @@ jobs:
30 fetch-depth: 0
31
32 - name: Sync data from publish
33 + id: sync
34 env:
35 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
36 SYNC_BRANCH: sync/publish-to-main
@@ -130,7 +131,10 @@ jobs:
131 HEAD_SHA=$(git rev-parse HEAD)
132 CHECK_COUNT=0
133 for _ in $(seq 1 30); do
133 - CHECK_COUNT=$(gh pr checks "$PR_NUMBER" --json name --jq 'length')
134 + # `gh pr checks` exits non-zero ("no checks reported") until checks
135 + # register; tolerate that under `set -e` so we keep polling instead of
136 + # killing the whole sync job (the bug that left the sync PR unmerged).
137 + CHECK_COUNT=$(gh pr checks "$PR_NUMBER" --json name --jq 'length' || echo 0)
138 if [ "$CHECK_COUNT" -gt 0 ]; then
139 break
140 fi
@@ -154,3 +158,79 @@ jobs:
158 # explicit merge fails, confirm the PR did in fact merge before succeeding.
159 gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA" || \
160 gh pr view "$PR_NUMBER" --json state --jq '.state' | grep -qx MERGED
161 +
162 + # Signal that the weekly content is now merged to main so the Podcaster
163 + # handoff (next step) can fire — and only now, never before the merge.
164 + echo "merged=true" >> "$GITHUB_OUTPUT"
165 +
166 + - name: Set up Python
167 + if: ${{ steps.sync.outputs.merged == 'true' }}
168 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
169 + with:
170 + python-version: '3.12'
171 +
172 + - name: Trigger Podcaster after merge
173 + if: ${{ steps.sync.outputs.merged == 'true' }}
174 + env:
175 + PODCASTER_ENDPOINT: ${{ vars.PODCASTER_ENDPOINT }}
176 + PODCASTER_API_KEY: ${{ secrets.PODCASTER_API_KEY }}
177 + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
178 + run: |
179 + set -euo pipefail
180 + if [ -z "$PODCASTER_ENDPOINT" ] || [ -z "$PODCASTER_API_KEY" ]; then
181 + echo "::notice::Podcaster handoff skipped — PODCASTER_ENDPOINT/PODCASTER_API_KEY not configured."
182 + exit 0
183 + fi
184 +
185 + # main now contains the merged weekly content. Identify the latest week.
186 + git fetch origin main
187 + git checkout -B main origin/main
188 + LATEST=$(git ls-files 'content/weekly/*/W*.md' | sort -V | tail -1)
189 + if [ -z "$LATEST" ]; then
190 + echo "::notice::No weekly article on main; skipping Podcaster handoff."
191 + exit 0
192 + fi
193 + YEAR=$(basename "$(dirname "$LATEST")")
194 + SHORT=$(basename "$LATEST" .md)
195 + WEEK="${YEAR}-${SHORT}"
196 + # Derive the canonical URL via the shared helper to avoid slug drift.
197 + ARTICLE_URL=$(python3 - "$LATEST" <<'PY'
198 + import sys
199 + from scripts.podcaster_handoff import article_url_from_page_path
200 +
201 + print(article_url_from_page_path("https://claracle.com/", sys.argv[1]))
202 + PY
203 + )
204 +
205 + # Locate the publish manifest produced by crawl-and-publish for this week.
206 + # Surface publish access problems as warnings instead of swallowing them —
207 + # a broken publish branch should be visible in the Actions log, not silent.
208 + git fetch origin publish
209 + # Clear any stale candidates and fail closed: if the publish checkout
210 + # fails we skip the handoff rather than risk using an incorrect manifest.
211 + rm -rf data/candidates/
212 + if ! git checkout origin/publish -- data/candidates/; then
213 + echo "::warning::Could not check out data/candidates/ from publish; skipping Podcaster handoff."
214 + exit 0
215 + fi
216 + MANIFEST=$(find "data/candidates/${WEEK}" -name 'publish-manifest.json' -type f 2>/dev/null | sort -V | tail -1)
217 + if [ -z "$MANIFEST" ]; then
218 + echo "::warning::No manifest for ${WEEK}; skipping Podcaster handoff."
219 + exit 0
220 + fi
221 + if ! python3 scripts/publish_manifest.py assert-eligible --manifest "$MANIFEST"; then
222 + echo "::notice::Manifest not eligible; skipping Podcaster handoff."
223 + exit 0
224 + fi
225 +
226 + # --require-merged fails closed unless the merged article exists and its
227 + # sha256 matches the manifest, so the Podcaster is never triggered for a stub.
228 + python3 scripts/podcaster_handoff.py \
229 + --week "$WEEK" \
230 + --article-url "$ARTICLE_URL" \
231 + --article-path "$LATEST" \
232 + --publish-run-id "$(basename "$(dirname "$MANIFEST")")" \
233 + --publish-mode normal \
234 + --manifest "$MANIFEST" \
235 + --podcast-config config/podcast.json \
236 + --require-merged
docs/pipeline-validation.md
+1 -1
@@ -118,7 +118,7 @@ Required secrets/tokens:
118 - `crawl` → later runs: `crawl-cache`
119 - `analyze` → `generate`: `analyzed-data`
120 - `generate` → `deploy`: `generated-content`
121 -- `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 +- Podcaster handoff: triggered from `sync-publish-to-main` only **after** the weekly article is merged into `main`. The handoff runs `scripts/podcaster_handoff.py --require-merged`, which fails closed unless the merged article exists and its sha256 matches the manifest `candidate.content_sha256`. This prevents the prior race where `deploy` (built from artifacts, pre-merge) could trigger the podcaster before the article was merged, producing stub episodes (e.g. W27).
122 - `crawl` and `analyze` also feed `deploy` so the final build uses the same run's data artifacts
123
124 ## Manual validation flow
scripts/podcaster_handoff.py
+56 -1
@@ -2,6 +2,7 @@
2 from __future__ import annotations
3
4 import argparse
5 +import hashlib
6 import json
7 import os
8 import re
@@ -83,6 +84,13 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
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)
@@ -543,6 +551,49 @@ def _read_article_content(
551 return content, title, summary
552
553
554 +def verify_article_merged(
555 + article_path: str,
556 + manifest: dict[str, Any],
557 + *,
558 + repo_root: Path = REPO_ROOT,
559 +) -> str:
560 + """Fail closed unless the merged article matches the manifest checksum.
561 +
562 + Verifies the article file exists locally (i.e. the weekly article has been
563 + merged to main) and that its sha256 matches the manifest
564 + candidate.content_sha256. Returns the verified sha256. Raises
565 + PodcasterHandoffError otherwise so the podcaster is never triggered for a
566 + missing/stub article before the article merge is complete.
567 + """
568 + candidate = manifest.get("candidate") if isinstance(manifest, dict) else None
569 + expected = candidate.get("content_sha256") if isinstance(candidate, dict) else None
570 + if not isinstance(expected, str) or not re.fullmatch(r"[0-9a-f]{64}", expected):
571 + raise PodcasterHandoffError(
572 + "Manifest lacks a valid candidate.content_sha256; cannot verify the merged article."
573 + )
574 + resolved = (repo_root / article_path).resolve()
575 + try:
576 + resolved.relative_to(repo_root.resolve())
577 + except ValueError:
578 + raise PodcasterHandoffError(
579 + f"article_path resolves outside the repository root: {article_path}"
580 + )
581 + if not resolved.is_file():
582 + raise PodcasterHandoffError(
583 + f"Article not merged yet: {article_path} is not present. "
584 + "Trigger the handoff only after the weekly article is merged to main."
585 + )
586 + try:
587 + actual = hashlib.sha256(resolved.read_bytes()).hexdigest()
588 + except OSError as exc:
589 + raise PodcasterHandoffError(f"Could not read merged article {article_path}: {exc}") from exc
590 + if actual != expected:
591 + raise PodcasterHandoffError(
592 + f"Merged article sha256 mismatch for {article_path}: expected {expected}, got {actual}."
593 + )
594 + return actual
595 +
596 +
597 def _manifest_allows_handoff(manifest: dict[str, Any], *, week: str, publish_mode: str) -> bool:
598 if not manifest:
599 return True
@@ -572,11 +623,15 @@ def build_payload(
623 podcaster_dry_run: bool = False,
624 repo_root: Path | None = None,
625 breaking_news: str | None = None,
626 + require_merged: bool = False,
627 ) -> dict[str, Any]:
628 manifest = _load_manifest(manifest_path)
629 if not _manifest_allows_handoff(manifest, week=week, publish_mode=publish_mode):
630 raise PodcasterHandoffError("Publish manifest is not eligible for Podcaster handoff.")
631 normalized_path = normalize_page_path(article_path)
632 + root = repo_root if repo_root is not None else REPO_ROOT
633 + if require_merged:
634 + verify_article_merged(normalized_path, manifest, repo_root=root)
635 payload: dict[str, Any] = {
636 "week": week,
637 "article_url": article_url,
@@ -586,7 +641,6 @@ def build_payload(
641 }
642
643 # Read article content and extract title
589 - root = repo_root if repo_root is not None else REPO_ROOT
644 content, title, summary = _read_article_content(normalized_path, repo_root=root)
645 if content:
646 payload["article_content"] = content
@@ -733,6 +787,7 @@ def main(argv: list[str] | None = None) -> int:
787 podcast_config_path=args.podcast_config,
788 podcaster_dry_run=args.podcaster_dry_run,
789 breaking_news=args.breaking_news,
790 + require_merged=args.require_merged,
791 )
792 post_handoff(endpoint, api_key, payload, timeout=args.timeout)
793 except PodcasterHandoffError as exc:
tests/test_pipeline.py
+19 -42
@@ -460,55 +460,32 @@ class WorkflowConfigTests(unittest.TestCase):
460 self.assertIn("📊 **SquadScope Week", webhook_run)
461 self.assertIn("Webhook post failed (non-critical)", webhook_run)
462
463 - def test_podcaster_handoff_runs_only_after_normal_deploy_without_blocking_deploy(self) -> None:
464 - workflow_path = Path(".github/workflows/crawl-and-publish.yml")
465 - workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))
466 -
467 - podcaster_job = workflow["jobs"]["podcaster-handoff"]
468 - self.assertEqual(podcaster_job["needs"], ["analyze", "generate", "deploy"])
469 - checkout_step = next(
470 - (s for s in podcaster_job["steps"] if s.get("name") == "Check out repository"), None
471 - )
472 - self.assertIsNotNone(checkout_step)
473 - self.assertTrue(_uses_action(checkout_step, "actions/checkout"))
474 - self.assertFalse(checkout_step["with"]["persist-credentials"])
475 - download_step = next(
476 - (s for s in podcaster_job["steps"] if s.get("name") == "Download analysis candidate"),
477 - None,
478 - )
479 - self.assertIsNotNone(download_step)
480 - self.assertTrue(_uses_action(download_step, "actions/download-artifact"))
481 - self.assertEqual(podcaster_job["if"], "${{ needs.analyze.outputs.run_mode == 'normal' }}")
482 - self.assertNotIn("continue-on-error", podcaster_job)
483 - self.assertNotIn("force-replace", podcaster_job["if"])
484 - self.assertNotIn("restore", podcaster_job["if"])
485 -
486 - deploy_job = workflow["jobs"]["deploy"]
463 + def test_podcaster_handoff_triggers_post_merge_from_sync_not_crawl(self) -> None:
464 + # Handoff must fire only AFTER the weekly article is merged to main, so it
465 + # lives in sync-publish-to-main (post-merge), not in crawl-and-publish
466 + # (which deploys from artifacts before the merge — the W27 stub race).
467 + crawl = yaml.safe_load(
468 + Path(".github/workflows/crawl-and-publish.yml").read_text(encoding="utf-8")
469 + )
470 + self.assertNotIn("podcaster-handoff", crawl["jobs"])
471 + deploy_job = crawl["jobs"]["deploy"]
472 self.assertEqual(deploy_job["needs"], ["crawl", "analyze", "generate"])
488 - self.assertNotIn("podcaster-handoff", deploy_job["needs"])
473
490 - notify_step = next(
491 - (s for s in podcaster_job["steps"] if s.get("name") == "Notify Podcaster"), None
492 - )
493 - self.assertIsNotNone(notify_step)
494 - self.assertEqual(notify_step["env"]["PODCASTER_ENDPOINT"], "${{ vars.PODCASTER_ENDPOINT }}")
495 - self.assertEqual(
496 - notify_step["env"]["PODCASTER_API_KEY"], "${{ secrets.PODCASTER_API_KEY }}"
474 + sync = yaml.safe_load(
475 + Path(".github/workflows/sync-publish-to-main.yml").read_text(encoding="utf-8")
476 )
498 - run_script = notify_step["run"]
499 - self.assertIn("article_url_from_page_path", run_script)
477 + steps = sync["jobs"]["sync"]["steps"]
478 + trigger = next((s for s in steps if s.get("name") == "Trigger Podcaster after merge"), None)
479 + self.assertIsNotNone(trigger)
480 + self.assertEqual(trigger["if"], "${{ steps.sync.outputs.merged == 'true' }}")
481 + self.assertEqual(trigger["env"]["PODCASTER_ENDPOINT"], "${{ vars.PODCASTER_ENDPOINT }}")
482 + self.assertEqual(trigger["env"]["PODCASTER_API_KEY"], "${{ secrets.PODCASTER_API_KEY }}")
483 + run_script = trigger["run"]
484 self.assertIn("scripts/publish_manifest.py assert-eligible", run_script)
501 - self.assertIn("publish manifest is not eligible", run_script)
485 self.assertIn("scripts/podcaster_handoff.py", run_script)
503 - self.assertIn('--article-path "$PAGE_PATH"', run_script)
504 - self.assertIn('--publish-run-id "$PUBLISH_RUN_ID"', run_script)
505 - self.assertIn('--publish-mode "$RUN_MODE"', run_script)
506 - self.assertIn('--manifest "$MANIFEST_FILE"', run_script)
507 - self.assertNotIn("weekly article publication remains complete", run_script)
486 + self.assertIn("--require-merged", run_script)
487 self.assertNotIn("--force", run_script)
509 - self.assertNotIn("--dry-run", run_script)
488 self.assertNotIn("echo $PODCASTER_API_KEY", run_script)
511 - self.assertNotIn("curl", run_script)
489
490 def test_podcaster_smoke_workflow_exercises_real_weekly_payload_shape(self) -> None:
491 workflow_path = Path(".github/workflows/podcaster-handoff-smoke.yml")
tests/test_podcaster_handoff.py
+67 -1
@@ -70,7 +70,7 @@ class PodcasterHandoffTests(unittest.TestCase):
70 "week": "2026-W23",
71 "run_id": "123456789",
72 "run_mode": run_mode,
73 - "candidate": {"summary_sha256": "a" * 64},
73 + "candidate": {"summary_sha256": "a" * 64, "content_sha256": "c" * 64},
74 "analysis": {"ai_status": ai_status},
75 "promotion": {"eligible": True, "decision": "promote"},
76 "source_artifacts": [
@@ -918,6 +918,72 @@ class PodcasterHandoffTests(unittest.TestCase):
918 )
919 self.assertEqual(payload["breaking_news"], "Major outage at GitHub Actions today")
920
921 + def test_verify_article_merged_passes_on_match(self) -> None:
922 + import hashlib
923 +
924 + with tempfile.TemporaryDirectory() as tmp:
925 + root = Path(tmp)
926 + article = root / "content" / "weekly" / "2026" / "W27.md"
927 + article.parent.mkdir(parents=True, exist_ok=True)
928 + article.write_text("# W27\nbody", encoding="utf-8")
929 + sha = hashlib.sha256(article.read_bytes()).hexdigest()
930 + manifest = {"candidate": {"content_sha256": sha}}
931 + self.assertEqual(
932 + podcaster_handoff.verify_article_merged(
933 + "content/weekly/2026/W27.md", manifest, repo_root=root
934 + ),
935 + sha,
936 + )
937 +
938 + def test_verify_article_merged_raises_when_missing(self) -> None:
939 + with tempfile.TemporaryDirectory() as tmp:
940 + manifest = {"candidate": {"content_sha256": "a" * 64}}
941 + with self.assertRaises(podcaster_handoff.PodcasterHandoffError):
942 + podcaster_handoff.verify_article_merged(
943 + "content/weekly/2026/W27.md", manifest, repo_root=Path(tmp)
944 + )
945 +
946 + def test_verify_article_merged_raises_on_sha_mismatch(self) -> None:
947 + with tempfile.TemporaryDirectory() as tmp:
948 + root = Path(tmp)
949 + article = root / "content" / "weekly" / "2026" / "W27.md"
950 + article.parent.mkdir(parents=True, exist_ok=True)
951 + article.write_text("# W27\nbody", encoding="utf-8")
952 + manifest = {"candidate": {"content_sha256": "f" * 64}}
953 + with self.assertRaises(podcaster_handoff.PodcasterHandoffError):
954 + podcaster_handoff.verify_article_merged(
955 + "content/weekly/2026/W27.md", manifest, repo_root=root
956 + )
957 +
958 + def test_verify_article_merged_rejects_non_hex_sha256(self) -> None:
959 + with tempfile.TemporaryDirectory() as tmp:
960 + root = Path(tmp)
961 + article = root / "content" / "weekly" / "2026" / "W27.md"
962 + article.parent.mkdir(parents=True, exist_ok=True)
963 + article.write_text("# W27\nbody", encoding="utf-8")
964 + manifest = {"candidate": {"content_sha256": "g" * 64}}
965 + with self.assertRaises(podcaster_handoff.PodcasterHandoffError):
966 + podcaster_handoff.verify_article_merged(
967 + "content/weekly/2026/W27.md", manifest, repo_root=root
968 + )
969 +
970 + def test_require_merged_fails_closed_for_unmerged_article(self) -> None:
971 + with tempfile.TemporaryDirectory() as tmp:
972 + base = Path(tmp)
973 + self._write_manifest(base)
974 + manifest = base / "publish-manifest.json"
975 + with self.assertRaises(podcaster_handoff.PodcasterHandoffError):
976 + podcaster_handoff.build_payload(
977 + week="2026-W23",
978 + article_url="https://claracle.com/weekly/2026/w23/",
979 + article_path="content/weekly/2026/W23.md",
980 + publish_run_id="123456789",
981 + publish_mode="normal",
982 + manifest_path=manifest,
983 + repo_root=base,
984 + require_merged=True,
985 + )
986 +
987
988 if __name__ == "__main__":
989 unittest.main()