Add publish backups and concurrency safeguards (#280)
* fix: harden publish backups and concurrency Create immutable publish backups before weekly replacements, record source provenance for restore audits, and add expected-ref safeguards around publish branch promotions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: restore publish backup helper availability Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: harden publish backup safety 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:04 UTC
1de643b0a73821b8dfb5295cfcd8ec10ea6b1bb3
8 files changed
+588
-12
.github/workflows/crawl-and-publish.yml
+84
-10
@@ -247,8 +247,10 @@ jobs:
247
cp -r data/snapshots crawl-snapshots-backup
248
# Fetch or create the unprotected data branch
249
if git fetch origin "$DATA_BRANCH" 2>/dev/null; then
250
+ EXPECTED_PUBLISH_SHA=$(git rev-parse "origin/$DATA_BRANCH")
251
git checkout -f -B "$DATA_BRANCH" "origin/$DATA_BRANCH"
252
else
253
+ EXPECTED_PUBLISH_SHA=""
254
git checkout -f -B "$DATA_BRANCH" "origin/$DEFAULT_BRANCH"
255
fi
256
# Restore crawl output on top of data branch
@@ -267,7 +269,11 @@ jobs:
269
git add data/raw/ data/snapshots/ .squad/run-counter.txt
270
git diff --cached --quiet && exit 0
271
git commit -m "data: weekly crawl $WEEK [run #${GITHUB_RUN_ID}]"
270
- git push origin "$DATA_BRANCH"
272
+ if [ -n "$EXPECTED_PUBLISH_SHA" ]; then
273
+ git push --force-with-lease="refs/heads/$DATA_BRANCH:$EXPECTED_PUBLISH_SHA" origin HEAD:"$DATA_BRANCH"
274
+ else
275
+ git push origin HEAD:"$DATA_BRANCH"
276
+ fi
277
278
analyze:
279
needs: crawl
@@ -282,6 +288,8 @@ jobs:
288
candidate_summary_file: ${{ steps.analysis-context.outputs.candidate_output_file }}
289
publish_manifest_file: ${{ steps.analysis-context.outputs.publish_manifest_file }}
290
current_datetime: ${{ steps.analysis-context.outputs.current_datetime }}
291
+ publish_base_sha: ${{ steps.publish-base.outputs.sha }}
292
+ publish_head_sha: ${{ steps.commit-analysis.outputs.publish_head_sha || steps.publish-base.outputs.sha }}
293
run_mode: ${{ steps.analysis-context.outputs.run_mode }}
294
295
steps:
@@ -319,6 +327,16 @@ jobs:
327
git checkout origin/publish -- "$f" 2>/dev/null || true
328
done
329
330
+ - name: Record publish branch base
331
+ id: publish-base
332
+ run: |
333
+ set -euo pipefail
334
+ if git fetch origin publish 2>/dev/null; then
335
+ echo "sha=$(git rev-parse origin/publish)" >> "$GITHUB_OUTPUT"
336
+ else
337
+ echo "sha=" >> "$GITHUB_OUTPUT"
338
+ fi
339
+
340
- name: Set up Node
341
uses: actions/setup-node@v4
342
with:
@@ -716,6 +734,7 @@ jobs:
734
run: python3 scripts/publish_manifest.py assert-eligible --manifest "${{ steps.analysis-context.outputs.publish_manifest_file }}"
735
736
- name: Commit analysis and learnings to data branch
737
+ id: commit-analysis
738
if: ${{ steps.analysis-context.outputs.run_mode != 'dry-run' && steps.analysis-context.outputs.run_mode != 'candidate-only' }}
739
env:
740
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
@@ -724,17 +743,19 @@ jobs:
743
CANDIDATE_SUMMARY: ${{ steps.analysis-context.outputs.candidate_output_file }}
744
PUBLISHED_SUMMARY: ${{ steps.analysis-context.outputs.published_output_file }}
745
MANIFEST_FILE: ${{ steps.analysis-context.outputs.publish_manifest_file }}
746
+ EXPECTED_PUBLISH_SHA: ${{ steps.publish-base.outputs.sha }}
747
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
748
run: |
749
set -euo pipefail
750
python3 scripts/publish_manifest.py assert-eligible --manifest "$MANIFEST_FILE"
751
cp scripts/publish_manifest.py publish-manifest-tool.py
752
+ cp scripts/publish_safety.py publish-safety-tool.py
753
git config user.name "github-actions[bot]"
754
git config user.email "github-actions[bot]@users.noreply.github.com"
755
# Check for analysis data OR squad learning state changes
756
if ! git status --short -- data/analyzed data/candidates data/metrics .squad | grep -q .; then
757
echo "No analyzed data, token usage, or learning state changes to commit."
737
- rm -f publish-manifest-tool.py
758
+ rm -f publish-manifest-tool.py publish-safety-tool.py
759
exit 0
760
fi
761
mkdir -p "$(dirname "$PUBLISHED_SUMMARY")"
@@ -751,31 +772,59 @@ jobs:
772
fi
773
# Push to the unprotected data branch
774
if git fetch origin "$DATA_BRANCH" 2>/dev/null; then
775
+ CURRENT_PUBLISH_SHA=$(git rev-parse "origin/$DATA_BRANCH")
776
+ if [ -n "$EXPECTED_PUBLISH_SHA" ] && [ "$CURRENT_PUBLISH_SHA" != "$EXPECTED_PUBLISH_SHA" ]; then
777
+ echo "::error::Publish branch drifted since analysis began: expected $EXPECTED_PUBLISH_SHA, found $CURRENT_PUBLISH_SHA"
778
+ exit 1
779
+ fi
780
git checkout -f -B "$DATA_BRANCH" "origin/$DATA_BRANCH"
781
else
782
+ CURRENT_PUBLISH_SHA=""
783
+ if [ -n "$EXPECTED_PUBLISH_SHA" ]; then
784
+ echo "::error::Publish branch disappeared since analysis began: expected $EXPECTED_PUBLISH_SHA"
785
+ exit 1
786
+ fi
787
git fetch origin "$DEFAULT_BRANCH"
788
git checkout -f -B "$DATA_BRANCH" "origin/$DEFAULT_BRANCH"
789
fi
790
mkdir -p data/analyzed data/candidates data/metrics
791
python3 publish-manifest-tool.py assert-eligible --manifest "candidates-data-backup/${WEEK}/${GITHUB_RUN_ID}/publish-manifest.json"
792
+ cp -r "candidates-data-backup/${WEEK}" data/candidates/
793
+ python3 publish-safety-tool.py backup-existing \
794
+ --week "$WEEK" \
795
+ --run-id "$GITHUB_RUN_ID" \
796
+ --kind analysis \
797
+ --manifest "data/candidates/${WEEK}/${GITHUB_RUN_ID}/publish-manifest.json" \
798
+ --expected-publish-ref "$EXPECTED_PUBLISH_SHA" \
799
+ --actual-publish-ref "$CURRENT_PUBLISH_SHA" \
800
+ --path "data/analyzed/${WEEK}-summary.md" \
801
+ --path "data/analyzed/${WEEK}-correlations.json" \
802
+ --path "data/analyzed/${WEEK}-press-context.md"
803
# Only copy current week's analysis files — do not overwrite prior weeks
804
# Required: current week's promoted summary must be backed by an eligible manifest
805
cp "analyzed-data-backup/${WEEK}-summary.md" data/analyzed/
764
- cp -r "candidates-data-backup/${WEEK}" data/candidates/
806
# Optional sidecars: conditionally generated
807
cp "analyzed-data-backup/${WEEK}-correlations.json" data/analyzed/ 2>/dev/null || true
808
cp "analyzed-data-backup/${WEEK}-press-context.md" data/analyzed/ 2>/dev/null || true
809
cp -r metrics-data-backup/* data/metrics/ 2>/dev/null || true
769
- rm -rf analyzed-data-backup candidates-data-backup metrics-data-backup publish-manifest-tool.py
810
+ rm -rf analyzed-data-backup candidates-data-backup metrics-data-backup publish-manifest-tool.py publish-safety-tool.py
811
# Restore squad learning state
812
if [ "$HAS_LEARNINGS" = "true" ]; then
813
cp -r squad-learning-backup/* .squad/ 2>/dev/null || true
814
rm -rf squad-learning-backup
815
fi
775
- git add data/analyzed/ data/candidates/ data/metrics/ .squad/
776
- git diff --cached --quiet && exit 0
816
+ git add data/analyzed/ data/candidates/ data/metrics/ data/backups/ .squad/
817
+ if git diff --cached --quiet; then
818
+ echo "publish_head_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
819
+ exit 0
820
+ fi
821
git commit -m "analysis: weekly summary + learnings $WEEK [run #${GITHUB_RUN_ID}]"
778
- git push origin "$DATA_BRANCH"
822
+ if [ -n "$CURRENT_PUBLISH_SHA" ]; then
823
+ git push --force-with-lease="refs/heads/$DATA_BRANCH:$CURRENT_PUBLISH_SHA" origin HEAD:"$DATA_BRANCH"
824
+ else
825
+ git push origin HEAD:"$DATA_BRANCH"
826
+ fi
827
+ echo "publish_head_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
828
829
- name: Upload analyzed data
830
if: always()
@@ -877,14 +926,17 @@ jobs:
926
WEEK: ${{ needs.analyze.outputs.week }}
927
PAGE_PATH: ${{ steps.generate-content.outputs.page_path }}
928
MANIFEST_FILE: ${{ needs.analyze.outputs.publish_manifest_file }}
929
+ EXPECTED_PUBLISH_SHA: ${{ needs.analyze.outputs.publish_head_sha }}
930
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
931
run: |
932
set -euo pipefail
933
python3 scripts/publish_manifest.py assert-eligible --manifest "$MANIFEST_FILE"
934
+ cp scripts/publish_safety.py publish-safety-tool.py
935
git config user.name "github-actions[bot]"
936
git config user.email "github-actions[bot]@users.noreply.github.com"
937
if ! git status --short -- content/weekly content/monthly content/yearly | grep -q .; then
938
echo "No generated content changes to commit."
939
+ rm -f publish-safety-tool.py
940
exit 0
941
fi
942
cp -r content/weekly content-weekly-backup 2>/dev/null || true
@@ -892,8 +944,18 @@ jobs:
944
cp -r content/yearly content-yearly-backup 2>/dev/null || true
945
# Push to the unprotected data branch
946
if git fetch origin "$DATA_BRANCH" 2>/dev/null; then
947
+ CURRENT_PUBLISH_SHA=$(git rev-parse "origin/$DATA_BRANCH")
948
+ if [ -n "$EXPECTED_PUBLISH_SHA" ] && [ "$CURRENT_PUBLISH_SHA" != "$EXPECTED_PUBLISH_SHA" ]; then
949
+ echo "::error::Publish branch drifted between analyze and content promotion: expected $EXPECTED_PUBLISH_SHA, found $CURRENT_PUBLISH_SHA"
950
+ exit 1
951
+ fi
952
git checkout -f -B "$DATA_BRANCH" "origin/$DATA_BRANCH"
953
else
954
+ CURRENT_PUBLISH_SHA=""
955
+ if [ -n "$EXPECTED_PUBLISH_SHA" ]; then
956
+ echo "::error::Publish branch disappeared between analyze and content promotion: expected $EXPECTED_PUBLISH_SHA"
957
+ exit 1
958
+ fi
959
git fetch origin "$DEFAULT_BRANCH"
960
git checkout -f -B "$DATA_BRANCH" "origin/$DEFAULT_BRANCH"
961
fi
@@ -913,16 +975,28 @@ jobs:
975
esac
976
RELATIVE_FROM_WEEKLY="${PAGE_PATH#content/weekly/}"
977
mkdir -p "$(dirname "$PAGE_PATH")"
978
+ python3 publish-safety-tool.py backup-existing \
979
+ --week "$WEEK" \
980
+ --run-id "$GITHUB_RUN_ID" \
981
+ --kind content \
982
+ --manifest "$MANIFEST_FILE" \
983
+ --expected-publish-ref "$EXPECTED_PUBLISH_SHA" \
984
+ --actual-publish-ref "$CURRENT_PUBLISH_SHA" \
985
+ --path "$PAGE_PATH"
986
cp "content-weekly-backup/${RELATIVE_FROM_WEEKLY}" "$PAGE_PATH"
987
# Monthly/yearly rollups are safe to copy since generate_rollups.py was seeded
988
# with hydrated prior-week data (see Hydrate analyzed data step above)
989
cp -r content-monthly-backup/* content/monthly/ 2>/dev/null || true
990
cp -r content-yearly-backup/* content/yearly/ 2>/dev/null || true
921
- rm -rf content-weekly-backup content-monthly-backup content-yearly-backup
922
- git add content/weekly/ content/monthly/ content/yearly/
991
+ rm -rf content-weekly-backup content-monthly-backup content-yearly-backup publish-safety-tool.py
992
+ git add content/weekly/ content/monthly/ content/yearly/ data/backups/
993
git diff --cached --quiet && exit 0
994
git commit -m "content: weekly page $WEEK [run #${GITHUB_RUN_ID}]"
925
- git push origin "$DATA_BRANCH"
995
+ if [ -n "$CURRENT_PUBLISH_SHA" ]; then
996
+ git push --force-with-lease="refs/heads/$DATA_BRANCH:$CURRENT_PUBLISH_SHA" origin HEAD:"$DATA_BRANCH"
997
+ else
998
+ git push origin HEAD:"$DATA_BRANCH"
999
+ fi
1000
1001
- name: Upload generated content artifact
1002
uses: actions/upload-artifact@v4
.github/workflows/restore-publish-backup.yml
new
+53
@@ -0,0 +1,53 @@
1
+name: Restore publish backup
2
+
3
+on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ backup_manifest:
7
+ description: 'Immutable backup manifest on publish (for example data/backups/2026-W23/123/content/manifest.json).'
8
+ required: true
9
+ type: string
10
+
11
+permissions:
12
+ contents: write
13
+
14
+concurrency:
15
+ group: restore-publish-backup
16
+ cancel-in-progress: false
17
+
18
+jobs:
19
+ restore:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - name: Check out workflow source
23
+ uses: actions/checkout@v4
24
+ with:
25
+ ref: ${{ github.sha }}
26
+ path: workflow-source
27
+
28
+ - name: Check out publish
29
+ uses: actions/checkout@v4
30
+ with:
31
+ ref: publish
32
+ fetch-depth: 0
33
+ path: publish
34
+
35
+ - name: Restore immutable backup
36
+ env:
37
+ BACKUP_MANIFEST: ${{ inputs.backup_manifest }}
38
+ run: |
39
+ set -euo pipefail
40
+ cd publish
41
+ git config user.name "github-actions[bot]"
42
+ git config user.email "github-actions[bot]@users.noreply.github.com"
43
+ git fetch origin publish
44
+ EXPECTED_PUBLISH_SHA=$(git rev-parse origin/publish)
45
+ git checkout -f -B publish origin/publish
46
+ python3 ../workflow-source/scripts/publish_safety.py restore-backup --backup-manifest "$BACKUP_MANIFEST"
47
+ git add data/analyzed/ content/weekly/
48
+ if git diff --cached --quiet; then
49
+ echo "Backup restore produced no changes."
50
+ exit 0
51
+ fi
52
+ git commit -m "restore: publish backup ${BACKUP_MANIFEST}"
53
+ git push --force-with-lease="refs/heads/publish:$EXPECTED_PUBLISH_SHA" origin HEAD:publish
scripts/publish_manifest.py
+16
-2
@@ -319,17 +319,26 @@ def artifact_entry(
319
artifact_generated_at = payload.get("generated_at") or payload.get("crawled_at") or generated_at
320
else:
321
artifact_generated_at = generated_at
322
+ reuse_status = same_day_reuse_status(payload)
323
+ checksum = sha256_file(path)
324
entry: dict[str, Any] = {
325
"role": role,
326
"path": path.as_posix(),
327
"exists": path.exists(),
328
"size_bytes": path.stat().st_size if path.exists() else 0,
327
- "sha256": sha256_file(path),
329
+ "sha256": checksum,
330
"artifact_checksum": metadata.get("artifact_checksum"),
331
"week": payload.get("week") if isinstance(payload, dict) else None,
332
"crawled_at": payload.get("crawled_at") if isinstance(payload, dict) else None,
333
"generated_at": artifact_generated_at,
332
- "same_day_reuse": same_day_reuse_status(payload),
334
+ "same_day_reuse": reuse_status,
335
+ "provenance": {
336
+ "path": path.as_posix(),
337
+ "sha256": checksum,
338
+ "artifact_checksum": metadata.get("artifact_checksum"),
339
+ "generated_at": artifact_generated_at,
340
+ "same_day_reuse": reuse_status,
341
+ },
342
"freshness": freshness_for_json_artifact(role, week, payload, run_date=run_date, run_mode=run_mode) if path.suffix == ".json" else {"status": "not_applicable", "reasons": []},
343
}
344
if "source_status" in metadata:
@@ -701,6 +710,11 @@ def assert_eligible(args: argparse.Namespace) -> int:
710
for entry in source_artifacts:
711
if not isinstance(entry, dict) or not entry.get("sha256"):
712
raise SystemExit("Manifest source artifact is missing a checksum.")
713
+ provenance = entry.get("provenance")
714
+ if not isinstance(provenance, dict) or provenance.get("sha256") != entry.get("sha256"):
715
+ raise SystemExit("Manifest source artifact is missing auditable provenance.")
716
+ if not isinstance(provenance.get("same_day_reuse"), dict):
717
+ raise SystemExit("Manifest source artifact reuse provenance is missing.")
718
freshness = entry.get("freshness", {})
719
if isinstance(freshness, dict) and freshness.get("status") == "stale":
720
raise SystemExit(f"Manifest source artifact is stale: {entry.get('path')}")
scripts/publish_safety.py
new
+194
@@ -0,0 +1,194 @@
1
+#!/usr/bin/env python3
2
+from __future__ import annotations
3
+
4
+import argparse
5
+import hashlib
6
+import json
7
+import shutil
8
+from datetime import UTC, datetime
9
+from pathlib import Path
10
+from typing import Any
11
+
12
+
13
+BACKUP_SCHEMA_VERSION = "publish_backup_v1"
14
+PUBLISH_MANIFEST_SCHEMA_VERSION = "publish_eligibility_v1"
15
+
16
+
17
+def sha256_file(path: Path) -> str | None:
18
+ if not path.exists() or not path.is_file():
19
+ return None
20
+ digest = hashlib.sha256()
21
+ with path.open("rb") as handle:
22
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
23
+ digest.update(chunk)
24
+ return digest.hexdigest()
25
+
26
+
27
+def load_json(path: Path) -> dict[str, Any] | None:
28
+ try:
29
+ payload = json.loads(path.read_text(encoding="utf-8"))
30
+ except (FileNotFoundError, OSError, json.JSONDecodeError):
31
+ return None
32
+ return payload if isinstance(payload, dict) else None
33
+
34
+
35
+def relpath_under_root(root: Path, value: str) -> Path:
36
+ path = Path(value)
37
+ if path.is_absolute():
38
+ raise SystemExit(f"Path must be relative to repository root: {value}")
39
+ resolved_root = root.resolve()
40
+ resolved_path = (resolved_root / path).resolve()
41
+ try:
42
+ return resolved_path.relative_to(resolved_root)
43
+ except ValueError as exc:
44
+ raise SystemExit(f"Path must stay under repository root: {value}") from exc
45
+
46
+
47
+def path_under_root(root: Path, value: Path) -> tuple[Path, Path]:
48
+ resolved_root = root.resolve()
49
+ resolved_path = (value if value.is_absolute() else resolved_root / value).resolve()
50
+ try:
51
+ relative = resolved_path.relative_to(resolved_root)
52
+ except ValueError as exc:
53
+ raise SystemExit(f"Path must stay under repository root: {value}") from exc
54
+ return relative, resolved_path
55
+
56
+
57
+def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
58
+ parser = argparse.ArgumentParser(description="Publish-branch backup and restore safeguards.")
59
+ subparsers = parser.add_subparsers(dest="command", required=True)
60
+
61
+ backup = subparsers.add_parser("backup-existing", help="Create an immutable backup manifest for target paths.")
62
+ backup.add_argument("--root", default=".", type=Path)
63
+ backup.add_argument("--week", required=True)
64
+ backup.add_argument("--run-id", required=True)
65
+ backup.add_argument("--kind", required=True, choices=["analysis", "content"])
66
+ backup.add_argument("--manifest", required=True, type=Path, help="Publish eligibility manifest.")
67
+ backup.add_argument("--expected-publish-ref", default="")
68
+ backup.add_argument("--actual-publish-ref", default="")
69
+ backup.add_argument("--backup-root", default=Path("data/backups"), type=Path)
70
+ backup.add_argument("--path", action="append", required=True, help="Published path to snapshot before replacement.")
71
+
72
+ restore = subparsers.add_parser("restore-backup", help="Restore files from an immutable publish backup manifest.")
73
+ restore.add_argument("--root", default=".", type=Path)
74
+ restore.add_argument("--backup-manifest", required=True, type=Path)
75
+
76
+ return parser.parse_args(argv)
77
+
78
+
79
+def backup_existing(args: argparse.Namespace) -> int:
80
+ root = args.root.resolve()
81
+ source_manifest_relative = relpath_under_root(root, args.manifest.as_posix())
82
+ source_manifest = root / source_manifest_relative
83
+ source_manifest_payload = load_json(source_manifest)
84
+ if source_manifest_payload is None:
85
+ raise SystemExit(f"Publish manifest is missing or malformed: {source_manifest_relative.as_posix()}")
86
+ if source_manifest_payload.get("schema_version") != PUBLISH_MANIFEST_SCHEMA_VERSION:
87
+ raise SystemExit(f"Unsupported publish manifest schema: {source_manifest_payload.get('schema_version')!r}")
88
+ if not isinstance(source_manifest_payload.get("candidate"), dict):
89
+ raise SystemExit("Publish manifest lacks candidate block.")
90
+ if not isinstance(source_manifest_payload.get("source_artifacts"), list):
91
+ raise SystemExit("Publish manifest lacks source artifact provenance.")
92
+ if not isinstance(source_manifest_payload.get("analysis"), dict):
93
+ raise SystemExit("Publish manifest lacks analysis provenance.")
94
+
95
+ entries: list[dict[str, Any]] = []
96
+ for raw_path in args.path:
97
+ relative = relpath_under_root(root, raw_path)
98
+ source = root / relative
99
+ if source.exists() and not source.is_file():
100
+ raise SystemExit(f"Backup target must be a regular file: {relative.as_posix()}")
101
+ entries.append(
102
+ {
103
+ "path": relative.as_posix(),
104
+ "existed": source.exists(),
105
+ "size_bytes": source.stat().st_size if source.exists() else 0,
106
+ "sha256": sha256_file(source),
107
+ }
108
+ )
109
+
110
+ backup_root = root / relpath_under_root(root, args.backup_root.as_posix())
111
+ backup_dir = backup_root / args.week / args.run_id / args.kind
112
+ manifest_path = backup_dir / "manifest.json"
113
+ if manifest_path.exists():
114
+ raise SystemExit(f"Refusing to overwrite immutable backup manifest: {manifest_path}")
115
+ backup_dir.mkdir(parents=True, exist_ok=False)
116
+
117
+ files_dir = backup_dir / "files"
118
+ for entry in entries:
119
+ relative = Path(entry["path"])
120
+ source = root / relative
121
+ if source.exists():
122
+ destination = files_dir / relative
123
+ destination.parent.mkdir(parents=True, exist_ok=True)
124
+ shutil.copy2(source, destination)
125
+ entry["backup_path"] = destination.relative_to(root).as_posix()
126
+
127
+ manifest = {
128
+ "schema_version": BACKUP_SCHEMA_VERSION,
129
+ "created_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
130
+ "week": args.week,
131
+ "run_id": args.run_id,
132
+ "kind": args.kind,
133
+ "publish_ref": {
134
+ "expected": args.expected_publish_ref,
135
+ "actual": args.actual_publish_ref,
136
+ },
137
+ "source_manifest": {
138
+ "path": source_manifest_relative.as_posix(),
139
+ "sha256": sha256_file(source_manifest),
140
+ "candidate": source_manifest_payload.get("candidate") if source_manifest_payload else None,
141
+ "source_artifacts": source_manifest_payload.get("source_artifacts") if source_manifest_payload else None,
142
+ "analysis": source_manifest_payload.get("analysis") if source_manifest_payload else None,
143
+ },
144
+ "files": entries,
145
+ }
146
+ manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
147
+ print(f"Created immutable publish backup: {manifest_path.relative_to(root).as_posix()}")
148
+ return 0
149
+
150
+
151
+def restore_backup(args: argparse.Namespace) -> int:
152
+ root = args.root.resolve()
153
+ backup_manifest_relative, backup_manifest = path_under_root(root, args.backup_manifest)
154
+ payload = load_json(backup_manifest)
155
+ if payload is None:
156
+ raise SystemExit(f"Backup manifest is missing or malformed: {backup_manifest}")
157
+ if payload.get("schema_version") != BACKUP_SCHEMA_VERSION:
158
+ raise SystemExit(f"Unsupported backup schema: {payload.get('schema_version')!r}")
159
+ files = payload.get("files")
160
+ if not isinstance(files, list):
161
+ raise SystemExit("Backup manifest has no files list.")
162
+
163
+ for entry in files:
164
+ if not isinstance(entry, dict) or not isinstance(entry.get("path"), str):
165
+ raise SystemExit("Backup file entry is malformed.")
166
+ target_relative = relpath_under_root(root, entry["path"])
167
+ target = root / target_relative
168
+ if entry.get("existed") is True:
169
+ backup_path = entry.get("backup_path")
170
+ if not isinstance(backup_path, str):
171
+ raise SystemExit(f"Backup entry lacks backup_path for {entry['path']}")
172
+ source_relative = relpath_under_root(root, backup_path)
173
+ source = root / source_relative
174
+ if sha256_file(source) != entry.get("sha256"):
175
+ raise SystemExit(f"Backup checksum mismatch for {backup_path}")
176
+ target.parent.mkdir(parents=True, exist_ok=True)
177
+ shutil.copy2(source, target)
178
+ else:
179
+ target.unlink(missing_ok=True)
180
+ print(f"Restored publish backup: {backup_manifest_relative.as_posix()}")
181
+ return 0
182
+
183
+
184
+def main(argv: list[str] | None = None) -> int:
185
+ args = parse_args(argv)
186
+ if args.command == "backup-existing":
187
+ return backup_existing(args)
188
+ if args.command == "restore-backup":
189
+ return restore_backup(args)
190
+ raise AssertionError(args.command)
191
+
192
+
193
+if __name__ == "__main__":
194
+ raise SystemExit(main())
tests/test_pipeline.py
+12
@@ -399,6 +399,11 @@ class WorkflowConfigTests(unittest.TestCase):
399
self.assertIsNotNone(commit_step)
400
commit_run = commit_step["run"]
401
self.assertIn('assert-eligible --manifest "$MANIFEST_FILE"', commit_run)
402
+ self.assertIn("Publish branch drifted since analysis began", commit_run)
403
+ self.assertIn("publish_safety.py", commit_run)
404
+ self.assertIn("backup-existing", commit_run)
405
+ self.assertIn("data/backups/", commit_run)
406
+ self.assertIn("--force-with-lease", commit_run)
407
self.assertIn('cp "$CANDIDATE_SUMMARY" "$PUBLISHED_SUMMARY"', commit_run)
408
self.assertIn("git add data/analyzed/ data/candidates/", commit_run)
409
@@ -411,6 +416,13 @@ class WorkflowConfigTests(unittest.TestCase):
416
self.assertIsNotNone(generate_step)
417
self.assertIn('assert-eligible --manifest "$MANIFEST_FILE"', generate_step["run"])
418
419
+ content_commit_step = next((s for s in generate["steps"] if s.get("name") == "Commit generated content to data branch"), None)
420
+ self.assertIsNotNone(content_commit_step)
421
+ content_commit_run = content_commit_step["run"]
422
+ self.assertIn("Publish branch drifted between analyze and content promotion", content_commit_run)
423
+ self.assertIn("backup-existing", content_commit_run)
424
+ self.assertIn("--force-with-lease", content_commit_run)
425
+
426
def test_rerun_mode_inputs_and_guards_are_declared(self) -> None:
427
workflow_path = Path(".github/workflows/crawl-and-publish.yml")
428
workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))
tests/test_publish_manifest.py
+2
@@ -164,6 +164,8 @@ class PublishManifestTests(unittest.TestCase):
164
self.assertEqual(payload["promotion"]["decision"], "promote")
165
self.assertRegex(payload["candidate"]["summary_sha256"], r"^[0-9a-f]{64}$")
166
self.assertRegex(payload["source_artifacts"][0]["sha256"], r"^[0-9a-f]{64}$")
167
+ self.assertEqual(payload["source_artifacts"][0]["provenance"]["sha256"], payload["source_artifacts"][0]["sha256"])
168
+ self.assertEqual(payload["source_artifacts"][0]["provenance"]["same_day_reuse"]["status"], "not_reused")
169
self.assertEqual(assert_eligible_from_root(base, manifest), 0)
170
171
def test_no_ai_candidate_is_not_eligible(self) -> None:
tests/test_publish_safety.py
new
+206
@@ -0,0 +1,206 @@
1
+import json
2
+import tempfile
3
+import unittest
4
+from pathlib import Path
5
+
6
+from scripts import publish_safety
7
+
8
+
9
+class PublishSafetyTests(unittest.TestCase):
10
+ def write_manifest(self, root: Path) -> Path:
11
+ manifest = root / "data/candidates/2026-W23/99/publish-manifest.json"
12
+ manifest.parent.mkdir(parents=True, exist_ok=True)
13
+ manifest.write_text(
14
+ json.dumps(
15
+ {
16
+ "schema_version": "publish_eligibility_v1",
17
+ "candidate": {"summary_sha256": "candidate-sha"},
18
+ "source_artifacts": [{"path": "data/raw/2026-W23.json", "sha256": "raw-sha"}],
19
+ "analysis": {"source": "copilot-cli"},
20
+ }
21
+ ),
22
+ encoding="utf-8",
23
+ )
24
+ return manifest
25
+
26
+ def test_backup_existing_is_immutable_and_restorable_with_provenance(self) -> None:
27
+ tests_root = Path(__file__).resolve().parent
28
+ with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
29
+ root = Path(tmpdir)
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
+ source = root / "data/raw/2026-W23.json"
34
+ source.parent.mkdir(parents=True, exist_ok=True)
35
+ source.write_text('{"week":"2026-W23"}\n', encoding="utf-8")
36
+ manifest = self.write_manifest(root)
37
+
38
+ exit_code = publish_safety.main(
39
+ [
40
+ "backup-existing",
41
+ "--root",
42
+ str(root),
43
+ "--week",
44
+ "2026-W23",
45
+ "--run-id",
46
+ "99",
47
+ "--kind",
48
+ "content",
49
+ "--manifest",
50
+ "data/candidates/2026-W23/99/publish-manifest.json",
51
+ "--expected-publish-ref",
52
+ "abc",
53
+ "--actual-publish-ref",
54
+ "abc",
55
+ "--path",
56
+ "content/weekly/2026/W23.md",
57
+ ]
58
+ )
59
+
60
+ self.assertEqual(exit_code, 0)
61
+ backup_manifest = root / "data/backups/2026-W23/99/content/manifest.json"
62
+ payload = json.loads(backup_manifest.read_text(encoding="utf-8"))
63
+ self.assertEqual(payload["schema_version"], "publish_backup_v1")
64
+ self.assertEqual(payload["publish_ref"]["expected"], "abc")
65
+ self.assertEqual(payload["source_manifest"]["source_artifacts"][0]["sha256"], "raw-sha")
66
+ self.assertRegex(payload["files"][0]["sha256"], r"^[0-9a-f]{64}$")
67
+
68
+ with self.assertRaises(SystemExit):
69
+ publish_safety.main(
70
+ [
71
+ "backup-existing",
72
+ "--root",
73
+ str(root),
74
+ "--week",
75
+ "2026-W23",
76
+ "--run-id",
77
+ "99",
78
+ "--kind",
79
+ "content",
80
+ "--manifest",
81
+ "data/candidates/2026-W23/99/publish-manifest.json",
82
+ "--path",
83
+ "content/weekly/2026/W23.md",
84
+ ]
85
+ )
86
+
87
+ target.write_text("bad replacement\n", encoding="utf-8")
88
+ self.assertEqual(
89
+ publish_safety.main(["restore-backup", "--root", str(root), "--backup-manifest", str(backup_manifest)]),
90
+ 0,
91
+ )
92
+ self.assertEqual(target.read_text(encoding="utf-8"), "known good article\n")
93
+
94
+ def test_backup_existing_requires_loadable_publish_manifest(self) -> None:
95
+ tests_root = Path(__file__).resolve().parent
96
+ with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
97
+ root = Path(tmpdir)
98
+ target = root / "content/weekly/2026/W23.md"
99
+ target.parent.mkdir(parents=True, exist_ok=True)
100
+ target.write_text("known good article\n", encoding="utf-8")
101
+
102
+ with self.assertRaises(SystemExit):
103
+ publish_safety.main(
104
+ [
105
+ "backup-existing",
106
+ "--root",
107
+ str(root),
108
+ "--week",
109
+ "2026-W23",
110
+ "--run-id",
111
+ "99",
112
+ "--kind",
113
+ "content",
114
+ "--manifest",
115
+ "data/candidates/2026-W23/99/publish-manifest.json",
116
+ "--path",
117
+ "content/weekly/2026/W23.md",
118
+ ]
119
+ )
120
+ self.assertFalse((root / "data/backups").exists())
121
+
122
+ malformed_manifest = root / "data/candidates/2026-W23/99/publish-manifest.json"
123
+ malformed_manifest.parent.mkdir(parents=True, exist_ok=True)
124
+ malformed_manifest.write_text("{not json", encoding="utf-8")
125
+
126
+ with self.assertRaises(SystemExit):
127
+ publish_safety.main(
128
+ [
129
+ "backup-existing",
130
+ "--root",
131
+ str(root),
132
+ "--week",
133
+ "2026-W23",
134
+ "--run-id",
135
+ "99",
136
+ "--kind",
137
+ "content",
138
+ "--manifest",
139
+ "data/candidates/2026-W23/99/publish-manifest.json",
140
+ "--path",
141
+ "content/weekly/2026/W23.md",
142
+ ]
143
+ )
144
+ self.assertFalse((root / "data/backups").exists())
145
+
146
+ def test_backup_existing_rejects_non_file_targets(self) -> None:
147
+ tests_root = Path(__file__).resolve().parent
148
+ with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
149
+ root = Path(tmpdir)
150
+ self.write_manifest(root)
151
+ target_dir = root / "content/weekly/2026"
152
+ target_dir.mkdir(parents=True, exist_ok=True)
153
+
154
+ with self.assertRaises(SystemExit):
155
+ publish_safety.main(
156
+ [
157
+ "backup-existing",
158
+ "--root",
159
+ str(root),
160
+ "--week",
161
+ "2026-W23",
162
+ "--run-id",
163
+ "99",
164
+ "--kind",
165
+ "content",
166
+ "--manifest",
167
+ "data/candidates/2026-W23/99/publish-manifest.json",
168
+ "--path",
169
+ "content/weekly/2026",
170
+ ]
171
+ )
172
+ self.assertFalse((root / "data/backups").exists())
173
+
174
+ def test_restore_backup_rejects_manifest_outside_root(self) -> None:
175
+ tests_root = Path(__file__).resolve().parent
176
+ with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
177
+ root = Path(tmpdir)
178
+ outside_manifest = root.parent / f"{root.name}-outside-manifest.json"
179
+ outside_manifest.write_text('{"schema_version":"publish_backup_v1","files":[]}\n', encoding="utf-8")
180
+ try:
181
+ with self.assertRaises(SystemExit):
182
+ publish_safety.main(
183
+ [
184
+ "restore-backup",
185
+ "--root",
186
+ str(root),
187
+ "--backup-manifest",
188
+ str(outside_manifest),
189
+ ]
190
+ )
191
+ with self.assertRaises(SystemExit):
192
+ publish_safety.main(
193
+ [
194
+ "restore-backup",
195
+ "--root",
196
+ str(root),
197
+ "--backup-manifest",
198
+ "../outside-manifest.json",
199
+ ]
200
+ )
201
+ finally:
202
+ outside_manifest.unlink(missing_ok=True)
203
+
204
+
205
+if __name__ == "__main__":
206
+ unittest.main()
tests/test_sync_publish_workflow.py
+21
@@ -2,6 +2,7 @@ from pathlib import Path
2
3
4
WORKFLOW = Path(".github/workflows/sync-publish-to-main.yml")
5
+RESTORE_WORKFLOW = Path(".github/workflows/restore-publish-backup.yml")
6
7
8
def test_publish_sync_only_checks_out_generated_content_paths() -> None:
@@ -30,3 +31,23 @@ def test_publish_sync_refuses_staged_squad_changes() -> None:
31
assert "git diff --cached --name-only | grep -E '^\\.squad/'" in workflow
32
assert "data/raw/" in workflow
33
assert ".squad/**" in workflow
34
+
35
+
36
+def test_restore_publish_backup_workflow_uses_immutable_backup_manifest() -> None:
37
+ workflow = RESTORE_WORKFLOW.read_text(encoding="utf-8")
38
+
39
+ assert "backup_manifest" in workflow
40
+ assert "python3 ../workflow-source/scripts/publish_safety.py restore-backup" in workflow
41
+ assert "--force-with-lease" in workflow
42
+ assert "ref: publish" in workflow
43
+
44
+
45
+def test_restore_publish_backup_workflow_keeps_helper_available_after_publish_checkout() -> None:
46
+ workflow = RESTORE_WORKFLOW.read_text(encoding="utf-8")
47
+
48
+ assert "path: workflow-source" in workflow
49
+ assert "ref: ${{ github.sha }}" in workflow
50
+ assert "path: publish" in workflow
51
+ assert "cd publish" in workflow
52
+ assert "python3 scripts/publish_safety.py restore-backup" not in workflow
53
+ assert "python3 ../workflow-source/scripts/publish_safety.py restore-backup" in workflow