Make weekly promotion atomic (#284)

* Make weekly promotion atomic Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix promotion manifest backup 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 6, 2026 at 22:23 UTC 3a5cb975c105c32f8380b9d86b0cf8d355bed5b0
5 files changed +213 -126
.github/workflows/crawl-and-publish.yml
+56 -110
@@ -288,7 +288,7 @@ jobs:
288 publish_manifest_file: ${{ steps.analysis-context.outputs.publish_manifest_file }}
289 current_datetime: ${{ steps.analysis-context.outputs.current_datetime }}
290 publish_base_sha: ${{ steps.publish-base.outputs.sha }}
291 - publish_head_sha: ${{ steps.commit-analysis.outputs.publish_head_sha || steps.publish-base.outputs.sha }}
291 + publish_head_sha: ${{ steps.publish-base.outputs.sha }}
292 run_mode: ${{ steps.analysis-context.outputs.run_mode }}
293
294 steps:
@@ -761,99 +761,6 @@ jobs:
761 if: ${{ steps.analysis-context.outputs.run_mode != 'dry-run' && steps.analysis-context.outputs.run_mode != 'candidate-only' }}
762 run: python3 scripts/publish_manifest.py assert-eligible --manifest "${{ steps.analysis-context.outputs.publish_manifest_file }}"
763
764 - - name: Commit analysis and learnings to data branch
765 - id: commit-analysis
766 - if: ${{ steps.analysis-context.outputs.run_mode != 'dry-run' && steps.analysis-context.outputs.run_mode != 'candidate-only' }}
767 - env:
768 - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
769 - DATA_BRANCH: publish
770 - WEEK: ${{ steps.analysis-context.outputs.week }}
771 - CANDIDATE_SUMMARY: ${{ steps.analysis-context.outputs.candidate_output_file }}
772 - PUBLISHED_SUMMARY: ${{ steps.analysis-context.outputs.published_output_file }}
773 - MANIFEST_FILE: ${{ steps.analysis-context.outputs.publish_manifest_file }}
774 - EXPECTED_PUBLISH_SHA: ${{ steps.publish-base.outputs.sha }}
775 - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
776 - run: |
777 - set -euo pipefail
778 - python3 scripts/publish_manifest.py assert-eligible --manifest "$MANIFEST_FILE"
779 - cp scripts/publish_manifest.py publish-manifest-tool.py
780 - cp scripts/publish_safety.py publish-safety-tool.py
781 - git config user.name "github-actions[bot]"
782 - git config user.email "github-actions[bot]@users.noreply.github.com"
783 - # Check for analysis data OR squad learning state changes
784 - if ! git status --short -- data/analyzed data/candidates data/metrics .squad | grep -q .; then
785 - echo "No analyzed data, token usage, or learning state changes to commit."
786 - rm -f publish-manifest-tool.py publish-safety-tool.py
787 - exit 0
788 - fi
789 - mkdir -p "$(dirname "$PUBLISHED_SUMMARY")"
790 - cp "$CANDIDATE_SUMMARY" "$PUBLISHED_SUMMARY"
791 - cp -r data/analyzed analyzed-data-backup
792 - cp -r data/candidates candidates-data-backup
793 - cp -r data/metrics metrics-data-backup
794 - # Preserve any .squad changes written by the agent (learnings, skills)
795 - if git status --short -- .squad | grep -q .; then
796 - cp -r .squad squad-learning-backup
797 - HAS_LEARNINGS=true
798 - else
799 - HAS_LEARNINGS=false
800 - fi
801 - # Push to the unprotected data branch
802 - if git fetch origin "$DATA_BRANCH" 2>/dev/null; then
803 - CURRENT_PUBLISH_SHA=$(git rev-parse "origin/$DATA_BRANCH")
804 - if [ -n "$EXPECTED_PUBLISH_SHA" ] && [ "$CURRENT_PUBLISH_SHA" != "$EXPECTED_PUBLISH_SHA" ]; then
805 - echo "::error::Publish branch drifted since analysis began: expected $EXPECTED_PUBLISH_SHA, found $CURRENT_PUBLISH_SHA"
806 - exit 1
807 - fi
808 - git checkout -f -B "$DATA_BRANCH" "origin/$DATA_BRANCH"
809 - else
810 - CURRENT_PUBLISH_SHA=""
811 - if [ -n "$EXPECTED_PUBLISH_SHA" ]; then
812 - echo "::error::Publish branch disappeared since analysis began: expected $EXPECTED_PUBLISH_SHA"
813 - exit 1
814 - fi
815 - git fetch origin "$DEFAULT_BRANCH"
816 - git checkout -f -B "$DATA_BRANCH" "origin/$DEFAULT_BRANCH"
817 - fi
818 - mkdir -p data/analyzed data/candidates data/metrics
819 - python3 publish-manifest-tool.py assert-eligible --manifest "candidates-data-backup/${WEEK}/${GITHUB_RUN_ID}/publish-manifest.json"
820 - cp -r "candidates-data-backup/${WEEK}" data/candidates/
821 - python3 publish-safety-tool.py backup-existing \
822 - --week "$WEEK" \
823 - --run-id "$GITHUB_RUN_ID" \
824 - --kind analysis \
825 - --manifest "data/candidates/${WEEK}/${GITHUB_RUN_ID}/publish-manifest.json" \
826 - --expected-publish-ref "$EXPECTED_PUBLISH_SHA" \
827 - --actual-publish-ref "$CURRENT_PUBLISH_SHA" \
828 - --path "data/analyzed/${WEEK}-summary.md" \
829 - --path "data/analyzed/${WEEK}-correlations.json" \
830 - --path "data/analyzed/${WEEK}-press-context.md"
831 - # Only copy current week's analysis files — do not overwrite prior weeks
832 - # Required: current week's promoted summary must be backed by an eligible manifest
833 - cp "analyzed-data-backup/${WEEK}-summary.md" data/analyzed/
834 - # Optional sidecars: conditionally generated
835 - cp "analyzed-data-backup/${WEEK}-correlations.json" data/analyzed/ 2>/dev/null || true
836 - cp "analyzed-data-backup/${WEEK}-press-context.md" data/analyzed/ 2>/dev/null || true
837 - cp -r metrics-data-backup/* data/metrics/ 2>/dev/null || true
838 - rm -rf analyzed-data-backup candidates-data-backup metrics-data-backup publish-manifest-tool.py publish-safety-tool.py
839 - # Restore squad learning state
840 - if [ "$HAS_LEARNINGS" = "true" ]; then
841 - cp -r squad-learning-backup/* .squad/ 2>/dev/null || true
842 - rm -rf squad-learning-backup
843 - fi
844 - git add data/analyzed/ data/candidates/ data/metrics/ data/backups/ .squad/
845 - if git diff --cached --quiet; then
846 - echo "publish_head_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
847 - exit 0
848 - fi
849 - git commit -m "analysis: weekly summary + learnings $WEEK [run #${GITHUB_RUN_ID}]"
850 - if [ -n "$CURRENT_PUBLISH_SHA" ]; then
851 - git push --force-with-lease="refs/heads/$DATA_BRANCH:$CURRENT_PUBLISH_SHA" origin HEAD:"$DATA_BRANCH"
852 - else
853 - git push origin HEAD:"$DATA_BRANCH"
854 - fi
855 - echo "publish_head_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
856 -
764 - name: Upload analyzed data
765 if: always()
766 uses: actions/upload-artifact@v4
@@ -910,21 +817,38 @@ jobs:
817 - name: Generate weekly content
818 id: generate-content
819 env:
913 - SUMMARY_FILE: ${{ needs.analyze.outputs.summary_file }}
820 + WEEK: ${{ needs.analyze.outputs.week }}
821 + SUMMARY_FILE: ${{ needs.analyze.outputs.candidate_summary_file }}
822 MANIFEST_FILE: ${{ needs.analyze.outputs.publish_manifest_file }}
823 run: |
824 set -euo pipefail
825 python3 scripts/publish_manifest.py assert-eligible --manifest "$MANIFEST_FILE"
918 - python3 - <<'PYGEN' "$SUMMARY_FILE" >> "$GITHUB_OUTPUT"
826 + python3 - <<'PYGEN' "$WEEK" "$SUMMARY_FILE" "$MANIFEST_FILE" >> "$GITHUB_OUTPUT"
827 + import json
828 import sys
829 from pathlib import Path
830
831 import scripts.generate_content as generate_content
923 -
924 - summary_path = Path(sys.argv[1])
925 - page_path = generate_content.generate_content(summary_path)
926 - print(f"page_path={page_path.as_posix()}")
832 + import scripts.publish_manifest as publish_manifest
833 +
834 + week = sys.argv[1]
835 + summary_path = Path(sys.argv[2])
836 + manifest_path = Path(sys.argv[3])
837 + candidate_content = manifest_path.parent / f"{week}-content.md"
838 + page_path = generate_content.generate_content(summary_path, candidate_content)
839 +
840 + manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
841 + manifest["candidate_content_path"] = page_path.as_posix()
842 + manifest.setdefault("candidate", {})["content_path"] = page_path.as_posix()
843 + manifest["candidate"]["content_sha256"] = publish_manifest.sha256_file(page_path)
844 + manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
845 +
846 + final_page_path = generate_content.infer_output_path(week, Path.cwd())
847 + print(f"page_path={final_page_path.as_posix()}")
848 + print(f"candidate_content_path={page_path.as_posix()}")
849 PYGEN
850 + python3 scripts/publish_manifest.py assert-eligible --manifest "$MANIFEST_FILE"
851 + python3 scripts/promotion_guard.py --manifest "$MANIFEST_FILE"
852
853 - name: Hydrate analyzed data from publish
854 env:
@@ -960,13 +884,20 @@ jobs:
884 set -euo pipefail
885 python3 scripts/publish_manifest.py assert-eligible --manifest "$MANIFEST_FILE"
886 cp scripts/publish_safety.py publish-safety-tool.py
887 + cp scripts/promotion_guard.py promotion-guard-tool.py
888 git config user.name "github-actions[bot]"
889 git config user.email "github-actions[bot]@users.noreply.github.com"
965 - if ! git status --short -- content/weekly content/monthly content/yearly | grep -q .; then
966 - echo "No generated content changes to commit."
967 - rm -f publish-safety-tool.py
890 + python3 scripts/promotion_guard.py --manifest "$MANIFEST_FILE"
891 + python3 scripts/publish_manifest.py assert-eligible --manifest "$MANIFEST_FILE"
892 + if ! git status --short -- data/analyzed data/candidates data/metrics data/published content/weekly content/monthly content/yearly | grep -q .; then
893 + echo "No promoted analysis or generated content changes to commit."
894 + rm -f publish-safety-tool.py promotion-guard-tool.py
895 exit 0
896 fi
897 + cp -r data/analyzed analyzed-data-backup
898 + cp -r data/candidates candidates-data-backup
899 + cp -r data/metrics metrics-data-backup 2>/dev/null || true
900 + cp -r data/published published-data-backup 2>/dev/null || true
901 cp -r content/weekly content-weekly-backup 2>/dev/null || true
902 cp -r content/monthly content-monthly-backup 2>/dev/null || true
903 cp -r content/yearly content-yearly-backup 2>/dev/null || true
@@ -987,7 +918,7 @@ jobs:
918 git fetch origin "$DEFAULT_BRANCH"
919 git checkout -f -B "$DATA_BRANCH" "origin/$DEFAULT_BRANCH"
920 fi
990 - mkdir -p content/weekly content/monthly content/yearly
921 + mkdir -p data/analyzed data/candidates data/metrics data/published content/weekly content/monthly content/yearly
922 # generate_content.py returns an absolute path; normalize it before restoring
923 # the freshly generated page onto the publish branch checkout.
924 case "$PAGE_PATH" in
@@ -1003,6 +934,7 @@ jobs:
934 esac
935 RELATIVE_FROM_WEEKLY="${PAGE_PATH#content/weekly/}"
936 mkdir -p "$(dirname "$PAGE_PATH")"
937 + cp -r "candidates-data-backup/${WEEK}" data/candidates/
938 python3 publish-safety-tool.py backup-existing \
939 --week "$WEEK" \
940 --run-id "$GITHUB_RUN_ID" \
@@ -1010,16 +942,23 @@ jobs:
942 --manifest "$MANIFEST_FILE" \
943 --expected-publish-ref "$EXPECTED_PUBLISH_SHA" \
944 --actual-publish-ref "$CURRENT_PUBLISH_SHA" \
945 + --path "data/analyzed/${WEEK}-summary.md" \
946 + --path "data/analyzed/${WEEK}-correlations.json" \
947 + --path "data/analyzed/${WEEK}-press-context.md" \
948 + --path "data/published/${WEEK}/promotion-manifest.json" \
949 --path "$PAGE_PATH"
1014 - cp "content-weekly-backup/${RELATIVE_FROM_WEEKLY}" "$PAGE_PATH"
950 + cp -r metrics-data-backup/* data/metrics/ 2>/dev/null || true
951 + cp "analyzed-data-backup/${WEEK}-correlations.json" data/analyzed/ 2>/dev/null || true
952 + cp "analyzed-data-backup/${WEEK}-press-context.md" data/analyzed/ 2>/dev/null || true
953 + python3 promotion-guard-tool.py --manifest "data/candidates/${WEEK}/${GITHUB_RUN_ID}/publish-manifest.json"
954 # Monthly/yearly rollups are safe to copy since generate_rollups.py was seeded
955 # with hydrated prior-week data (see Hydrate analyzed data step above)
956 cp -r content-monthly-backup/* content/monthly/ 2>/dev/null || true
957 cp -r content-yearly-backup/* content/yearly/ 2>/dev/null || true
1019 - rm -rf content-weekly-backup content-monthly-backup content-yearly-backup publish-safety-tool.py
1020 - git add content/weekly/ content/monthly/ content/yearly/ data/backups/
958 + rm -rf analyzed-data-backup candidates-data-backup metrics-data-backup published-data-backup content-weekly-backup content-monthly-backup content-yearly-backup publish-safety-tool.py promotion-guard-tool.py
959 + git add data/analyzed/ data/candidates/ data/metrics/ data/published/ content/weekly/ content/monthly/ content/yearly/ data/backups/
960 git diff --cached --quiet && exit 0
1022 - git commit -m "content: weekly page $WEEK [run #${GITHUB_RUN_ID}]"
961 + git commit -m "publish: weekly article transaction $WEEK [run #${GITHUB_RUN_ID}]"
962 if [ -n "$CURRENT_PUBLISH_SHA" ]; then
963 git push --force-with-lease="refs/heads/$DATA_BRANCH:$CURRENT_PUBLISH_SHA" origin HEAD:"$DATA_BRANCH"
964 else
@@ -1036,6 +975,13 @@ jobs:
975 content/yearly/
976 if-no-files-found: warn
977
978 + - name: Upload promoted analyzed artifact
979 + uses: actions/upload-artifact@v4
980 + with:
981 + name: promoted-analyzed-data
982 + path: data/analyzed/
983 + if-no-files-found: warn
984 +
985 deploy:
986 needs: [crawl, analyze, generate]
987 if: ${{ needs.analyze.outputs.run_mode != 'dry-run' && needs.analyze.outputs.run_mode != 'candidate-only' }}
@@ -1071,7 +1017,7 @@ jobs:
1017 - name: Download analyzed data artifact
1018 uses: actions/download-artifact@v4
1019 with:
1074 - name: analyzed-data
1020 + name: promoted-analyzed-data
1021 path: data/analyzed/
1022
1023 - name: Download generated content artifact
@@ -1128,7 +1074,7 @@ jobs:
1074
1075 - uses: actions/download-artifact@v4
1076 with:
1131 - name: analyzed-data
1077 + name: promoted-analyzed-data
1078 path: data/analyzed/
1079
1080 - uses: actions/download-artifact@v4
scripts/promotion_guard.py
+119 -5
@@ -1,9 +1,10 @@
1 from __future__ import annotations
2
3 import argparse
4 +import hashlib
5 import json
6 +import os
7 import re
6 -import shutil
8 from datetime import UTC, date, datetime
9 from pathlib import Path
10 from typing import Any
@@ -18,6 +19,7 @@ class PromotionBlocked(ValueError):
19 WEEK_PATTERN = re.compile(r"^(?P<year>\d{4})-W(?P<week>\d{2})$")
20 FRONTMATTER_PATTERN = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)
21 FALLBACK_MIN_QUALITY_SCORE = 70
22 +PROMOTION_TRANSACTION_SCHEMA_VERSION = "promotion_transaction_v1"
23
24
25 def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
@@ -140,6 +142,20 @@ def _manifest_run_started_at(manifest: dict[str, Any]) -> Any:
142 return manifest.get("run_started_at") or manifest.get("generated_at")
143
144
145 +def _sha256_file(path: Path) -> str | None:
146 + if not path.exists() or not path.is_file():
147 + return None
148 + digest = hashlib.sha256()
149 + with path.open("rb") as handle:
150 + for chunk in iter(lambda: handle.read(1024 * 1024), b""):
151 + digest.update(chunk)
152 + return digest.hexdigest()
153 +
154 +
155 +def _relative_to_root(root: Path, path: Path) -> str:
156 + return path.resolve().relative_to(root.resolve()).as_posix()
157 +
158 +
159 def _artifact_reused_same_day(artifact: dict[str, Any]) -> bool:
160 if artifact.get("reused_same_day") is True:
161 return True
@@ -354,6 +370,87 @@ def _write_force_audit(root: Path, week: str, manifest: dict[str, Any]) -> Path
370 return audit_path
371
372
373 +def _promotion_transaction_record(
374 + *,
375 + root: Path,
376 + week: str,
377 + manifest_path: Path,
378 + manifest: dict[str, Any],
379 + candidate_summary: Path,
380 + candidate_content: Path,
381 + canonical_summary: Path,
382 + canonical_content: Path,
383 +) -> dict[str, Any]:
384 + manifest_relative = _relative_to_root(root, manifest_path)
385 + summary_sha = _sha256_file(candidate_summary)
386 + content_sha = _sha256_file(candidate_content)
387 + stable_record: dict[str, Any] = {
388 + "schema_version": PROMOTION_TRANSACTION_SCHEMA_VERSION,
389 + "week": week,
390 + "run_id": manifest.get("run_id"),
391 + "source_manifest": {
392 + "path": manifest_relative,
393 + "sha256": _sha256_file(manifest_path),
394 + },
395 + "candidate": {
396 + "summary_path": _relative_to_root(root, candidate_summary),
397 + "summary_sha256": summary_sha,
398 + "content_path": _relative_to_root(root, candidate_content),
399 + "content_sha256": content_sha,
400 + },
401 + "published_artifacts": [
402 + {
403 + "role": "analysis_summary",
404 + "path": _relative_to_root(root, canonical_summary),
405 + "source_path": _relative_to_root(root, candidate_summary),
406 + "sha256": summary_sha,
407 + },
408 + {
409 + "role": "hugo_content",
410 + "path": _relative_to_root(root, canonical_content),
411 + "source_path": _relative_to_root(root, candidate_content),
412 + "sha256": content_sha,
413 + },
414 + ],
415 + "provenance": {
416 + "source_artifacts": manifest.get("source_artifacts", []),
417 + "analysis": manifest.get("analysis") or manifest.get("ai_provenance"),
418 + "validation": manifest.get("validation") or {"gate_results": manifest.get("gate_results")},
419 + "promotion": manifest.get("promotion") or {"eligible": manifest.get("promotion_eligible")},
420 + },
421 + }
422 + transaction_payload = json.dumps(stable_record, sort_keys=True, separators=(",", ":")).encode("utf-8")
423 + stable_record["transaction_id"] = hashlib.sha256(transaction_payload).hexdigest()
424 + return stable_record
425 +
426 +
427 +def _write_transactionally(targets: list[tuple[Path, bytes]]) -> None:
428 + originals: list[tuple[Path, bool, bytes | None]] = []
429 + written: list[Path] = []
430 + temp_paths: list[Path] = []
431 + for target, _ in targets:
432 + originals.append((target, target.exists(), target.read_bytes() if target.exists() else None))
433 + target.parent.mkdir(parents=True, exist_ok=True)
434 +
435 + try:
436 + for index, (target, payload) in enumerate(targets):
437 + tmp = target.with_name(f".{target.name}.promotion-{os.getpid()}-{index}.tmp")
438 + temp_paths.append(tmp)
439 + tmp.write_bytes(payload)
440 + tmp.replace(target)
441 + written.append(target)
442 + except Exception:
443 + for tmp in temp_paths:
444 + tmp.unlink(missing_ok=True)
445 + for target, existed, payload in reversed(originals):
446 + if existed and payload is not None:
447 + target.parent.mkdir(parents=True, exist_ok=True)
448 + target.write_bytes(payload)
449 + elif target in written or target.exists():
450 + target.unlink(missing_ok=True)
451 + raise
452 +
453 +
454 def promote_candidate(manifest_path: Path, *, root: Path | None = None) -> tuple[Path, Path]:
455 workspace = (root or Path.cwd()).resolve()
456 resolved_manifest_path = manifest_path if manifest_path.is_absolute() else workspace / manifest_path
@@ -375,11 +472,28 @@ def promote_candidate(manifest_path: Path, *, root: Path | None = None) -> tuple
472 raise PromotionBlocked(["week must use YYYY-WNN format."])
473 year, week_number = match.group("year"), match.group("week")
474 canonical_content = workspace / "content" / "weekly" / year / f"W{week_number}.md"
378 - canonical_summary.parent.mkdir(parents=True, exist_ok=True)
379 - canonical_content.parent.mkdir(parents=True, exist_ok=True)
475 + transaction_manifest = workspace / "data" / "published" / week / "promotion-manifest.json"
476 _write_force_audit(workspace, week, manifest)
381 - shutil.copyfile(candidate_summary, canonical_summary)
382 - shutil.copyfile(candidate_content, canonical_content)
477 + transaction_record = _promotion_transaction_record(
478 + root=workspace,
479 + week=week,
480 + manifest_path=resolved_manifest_path,
481 + manifest=manifest,
482 + candidate_summary=candidate_summary,
483 + candidate_content=candidate_content,
484 + canonical_summary=canonical_summary,
485 + canonical_content=canonical_content,
486 + )
487 + _write_transactionally(
488 + [
489 + (canonical_summary, candidate_summary.read_bytes()),
490 + (canonical_content, candidate_content.read_bytes()),
491 + (
492 + transaction_manifest,
493 + (json.dumps(transaction_record, indent=2, sort_keys=True) + "\n").encode("utf-8"),
494 + ),
495 + ]
496 + )
497 return canonical_summary, canonical_content
498
499
tests/test_pipeline.py
+14 -11
@@ -307,7 +307,7 @@ class WorkflowConfigTests(unittest.TestCase):
307 self.assertIn("content/weekly", commit_run)
308 self.assertIn("content/monthly", commit_run)
309 self.assertIn("content/yearly", commit_run)
310 - self.assertIn("git add content/weekly/", commit_run)
310 + self.assertIn("content/weekly/", commit_run)
311 self.assertIn("content/monthly/", commit_run)
312 self.assertIn("content/yearly/", commit_run)
313 self.assertIn("GITHUB_WORKSPACE", commit_run)
@@ -318,6 +318,9 @@ class WorkflowConfigTests(unittest.TestCase):
318 self.assertIsNotNone(upload_step)
319 self.assertIn("content/monthly/", upload_step["with"]["path"])
320 self.assertIn("content/yearly/", upload_step["with"]["path"])
321 + promoted_upload = next((s for s in generate_job["steps"] if s.get("name") == "Upload promoted analyzed artifact"), None)
322 + self.assertIsNotNone(promoted_upload)
323 + self.assertEqual(promoted_upload["with"]["name"], "promoted-analyzed-data")
324
325 def test_sync_publish_to_main_excludes_squad_state_and_regenerates_rollups(self) -> None:
326 workflow_path = Path(".github/workflows/sync-publish-to-main.yml")
@@ -356,6 +359,9 @@ class WorkflowConfigTests(unittest.TestCase):
359
360 notify_job = workflow["jobs"]["notify"]
361 self.assertEqual(notify_job["needs"], ["analyze", "generate", "deploy"])
362 + analyzed_download = next((s for s in notify_job["steps"] if s.get("uses") == "actions/download-artifact@v4" and s.get("with", {}).get("path") == "data/analyzed/"), None)
363 + self.assertIsNotNone(analyzed_download)
364 + self.assertEqual(analyzed_download["with"]["name"], "promoted-analyzed-data")
365
366 webhook_step = next((s for s in notify_job["steps"] if s.get("name") == "Post to webhook"), None)
367 self.assertIsNotNone(webhook_step)
@@ -416,17 +422,9 @@ class WorkflowConfigTests(unittest.TestCase):
422 self.assertIsNotNone(assert_step)
423 self.assertIn("scripts/publish_manifest.py assert-eligible", assert_step["run"])
424
425 + self.assertEqual(analyze["outputs"]["publish_head_sha"], "${{ steps.publish-base.outputs.sha }}")
426 commit_step = next((s for s in analyze["steps"] if s.get("name") == "Commit analysis and learnings to data branch"), None)
420 - self.assertIsNotNone(commit_step)
421 - commit_run = commit_step["run"]
422 - self.assertIn('assert-eligible --manifest "$MANIFEST_FILE"', commit_run)
423 - self.assertIn("Publish branch drifted since analysis began", commit_run)
424 - self.assertIn("publish_safety.py", commit_run)
425 - self.assertIn("backup-existing", commit_run)
426 - self.assertIn("data/backups/", commit_run)
427 - self.assertIn("--force-with-lease", commit_run)
428 - self.assertIn('cp "$CANDIDATE_SUMMARY" "$PUBLISHED_SUMMARY"', commit_run)
429 - self.assertIn("git add data/analyzed/ data/candidates/", commit_run)
427 + self.assertIsNone(commit_step)
428
429 upload_candidate = next((s for s in analyze["steps"] if s.get("name") == "Upload analysis candidate"), None)
430 self.assertIsNotNone(upload_candidate)
@@ -436,12 +434,17 @@ class WorkflowConfigTests(unittest.TestCase):
434 generate_step = next((s for s in generate["steps"] if s.get("name") == "Generate weekly content"), None)
435 self.assertIsNotNone(generate_step)
436 self.assertIn('assert-eligible --manifest "$MANIFEST_FILE"', generate_step["run"])
437 + self.assertIn("candidate_content_path", generate_step["run"])
438 + self.assertIn("scripts/promotion_guard.py --manifest", generate_step["run"])
439
440 content_commit_step = next((s for s in generate["steps"] if s.get("name") == "Commit generated content to data branch"), None)
441 self.assertIsNotNone(content_commit_step)
442 content_commit_run = content_commit_step["run"]
443 self.assertIn("Publish branch drifted between analyze and content promotion", content_commit_run)
444 self.assertIn("backup-existing", content_commit_run)
445 + self.assertIn('--path "data/published/${WEEK}/promotion-manifest.json"', content_commit_run)
446 + self.assertIn("promotion-guard-tool.py --manifest", content_commit_run)
447 + self.assertIn("data/published/", content_commit_run)
448 self.assertIn("--force-with-lease", content_commit_run)
449
450 def test_rerun_mode_inputs_and_guards_are_declared(self) -> None:
tests/test_promotion_guard.py
+10
@@ -380,6 +380,16 @@ class PromotionGuardTests(unittest.TestCase):
380 self.assertEqual(second_content.read_text(encoding="utf-8"), first_content_text)
381 self.assertEqual(second_summary.read_text(encoding="utf-8").count("Better candidate analysis."), 1)
382 self.assertEqual(second_content.read_text(encoding="utf-8").count("Better candidate rendered content."), 1)
383 + transaction_path = root / "data/published/2026-W23/promotion-manifest.json"
384 + first_transaction = json.loads(transaction_path.read_text(encoding="utf-8"))
385 + promotion_guard.promote_candidate(manifest_path, root=root)
386 + second_transaction = json.loads(transaction_path.read_text(encoding="utf-8"))
387 + self.assertEqual(second_transaction, first_transaction)
388 + self.assertEqual(first_transaction["schema_version"], "promotion_transaction_v1")
389 + self.assertEqual(first_transaction["source_manifest"]["path"], "data/staging/2026-W23/valid/publish-manifest.json")
390 + self.assertEqual(first_transaction["provenance"]["source_artifacts"][0]["path"], "data/raw/2026-W23-valid.json")
391 + self.assertEqual(first_transaction["published_artifacts"][0]["path"], "data/analyzed/2026-W23-summary.md")
392 + self.assertEqual(first_transaction["published_artifacts"][1]["path"], "content/weekly/2026/W23.md")
393
394 def test_no_ai_first_publish_requires_explicit_policy_and_no_existing_good_article(self) -> None:
395 tests_root = Path(__file__).resolve().parent
tests/test_publish_safety.py
+14
@@ -30,6 +30,9 @@ class PublishSafetyTests(unittest.TestCase):
30 target = root / "content/weekly/2026/W23.md"
31 target.parent.mkdir(parents=True, exist_ok=True)
32 target.write_text("known good article\n", encoding="utf-8")
33 + transaction_manifest = root / "data/published/2026-W23/promotion-manifest.json"
34 + transaction_manifest.parent.mkdir(parents=True, exist_ok=True)
35 + transaction_manifest.write_text('{"schema_version":"promotion_transaction_v1","transaction_id":"old"}\n', encoding="utf-8")
36 source = root / "data/raw/2026-W23.json"
37 source.parent.mkdir(parents=True, exist_ok=True)
38 source.write_text('{"week":"2026-W23"}\n', encoding="utf-8")
@@ -54,6 +57,8 @@ class PublishSafetyTests(unittest.TestCase):
57 "abc",
58 "--path",
59 "content/weekly/2026/W23.md",
60 + "--path",
61 + "data/published/2026-W23/promotion-manifest.json",
62 ]
63 )
64
@@ -64,6 +69,9 @@ class PublishSafetyTests(unittest.TestCase):
69 self.assertEqual(payload["publish_ref"]["expected"], "abc")
70 self.assertEqual(payload["source_manifest"]["source_artifacts"][0]["sha256"], "raw-sha")
71 self.assertRegex(payload["files"][0]["sha256"], r"^[0-9a-f]{64}$")
72 + backed_up_paths = {entry["path"] for entry in payload["files"]}
73 + self.assertIn("content/weekly/2026/W23.md", backed_up_paths)
74 + self.assertIn("data/published/2026-W23/promotion-manifest.json", backed_up_paths)
75
76 with self.assertRaises(SystemExit):
77 publish_safety.main(
@@ -85,11 +93,17 @@ class PublishSafetyTests(unittest.TestCase):
93 )
94
95 target.write_text("bad replacement\n", encoding="utf-8")
96 + transaction_manifest.write_text(
97 + '{"schema_version":"promotion_transaction_v1","transaction_id":"new"}\n',
98 + encoding="utf-8",
99 + )
100 self.assertEqual(
101 publish_safety.main(["restore-backup", "--root", str(root), "--backup-manifest", str(backup_manifest)]),
102 0,
103 )
104 self.assertEqual(target.read_text(encoding="utf-8"), "known good article\n")
105 + restored_transaction = json.loads(transaction_manifest.read_text(encoding="utf-8"))
106 + self.assertEqual(restored_transaction["transaction_id"], "old")
107
108 def test_backup_existing_requires_loadable_publish_manifest(self) -> None:
109 tests_root = Path(__file__).resolve().parent