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