feat: stage weekly analysis candidates
Add publish eligibility manifests for weekly analysis candidates and require promotion paths to validate them before publishing. Closes #249 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Juan Manuel Servera committed
Jun 5, 2026 at 23:26 UTC
45be08ac62fe5abee2eda92468ee5f255d88e6d4
5 files changed
+574
-11
.github/workflows/crawl-and-publish.yml
+96
-11
@@ -202,7 +202,9 @@ jobs:
202
contents: write
203
outputs:
204
week: ${{ steps.analysis-context.outputs.week }}
205
- summary_file: ${{ steps.analysis-context.outputs.output_file }}
205
+ summary_file: ${{ steps.analysis-context.outputs.published_output_file }}
206
+ candidate_summary_file: ${{ steps.analysis-context.outputs.candidate_output_file }}
207
+ publish_manifest_file: ${{ steps.analysis-context.outputs.publish_manifest_file }}
208
current_datetime: ${{ steps.analysis-context.outputs.current_datetime }}
209
210
steps:
@@ -255,7 +257,7 @@ jobs:
257
env:
258
REBUILD_WEEK: ${{ inputs.rebuild_week || '' }}
259
run: |
258
- mkdir -p data/analyzed
260
+ mkdir -p data/analyzed data/candidates
261
CURRENT_DATETIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
262
readarray -t CONTEXT_LINES < <(python3 - <<'PY' "$CURRENT_DATETIME"
263
import json
@@ -280,7 +282,11 @@ jobs:
282
raise SystemExit(f"Raw payload week mismatch: expected {week}, found {payload.get('week')!r}")
283
print(f"week_file={week_file.as_posix()}")
284
print(f"week={week}")
283
- print(f"output_file=data/analyzed/{week}-summary.md")
285
+ run_id = os.environ.get("GITHUB_RUN_ID", "local")
286
+ candidate_dir = Path("data/candidates") / week / run_id
287
+ print(f"candidate_output_file={(candidate_dir / f'{week}-summary.md').as_posix()}")
288
+ print(f"publish_manifest_file={(candidate_dir / 'publish-manifest.json').as_posix()}")
289
+ print(f"published_output_file=data/analyzed/{week}-summary.md")
290
PY
291
)
292
{
@@ -357,13 +363,13 @@ jobs:
363
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
364
run: |
365
set -euo pipefail
360
- OUTPUT_FILE="${{ steps.analysis-context.outputs.output_file }}"
366
+ OUTPUT_FILE="${{ steps.analysis-context.outputs.candidate_output_file }}"
367
WEEK_FILE="${{ steps.analysis-context.outputs.week_file }}"
368
WEEK="${{ steps.analysis-context.outputs.week }}"
369
CURRENT_DATETIME="${{ steps.analysis-context.outputs.current_datetime }}"
370
PRESS_FILE="${{ steps.press-context.outputs.press_file }}"
371
ANALYSIS_STARTED=$(date +%s)
366
- mkdir -p data/metrics
372
+ mkdir -p data/metrics "$(dirname "$OUTPUT_FILE")"
373
# Hydrate metrics ledger from publish so track_token_usage.py appends to
374
# the canonical token-usage.jsonl rather than starting fresh each run.
375
git fetch origin publish 2>/dev/null && \
@@ -507,10 +513,11 @@ jobs:
513
# Clean up per-attempt transcripts
514
rm -f data/metrics/copilot-transcript-attempt-*.md
515
echo "analysis_source=$ANALYSIS_SOURCE" >> "$GITHUB_OUTPUT"
516
+ echo "analysis_model=$ANALYSIS_MODEL" >> "$GITHUB_OUTPUT"
517
518
- name: quality-check
519
env:
513
- ANALYSIS_FILE: ${{ steps.analysis-context.outputs.output_file }}
520
+ ANALYSIS_FILE: ${{ steps.analysis-context.outputs.candidate_output_file }}
521
ANALYSIS_SOURCE: ${{ steps.run-analysis.outputs.analysis_source }}
522
RAW_JSON_FILE: ${{ steps.analysis-context.outputs.week_file }}
523
CURRENT_DATETIME: ${{ steps.analysis-context.outputs.current_datetime }}
@@ -521,22 +528,70 @@ jobs:
528
--current-datetime "$CURRENT_DATETIME" \
529
--source "$ANALYSIS_SOURCE"
530
531
+ - name: Emit publish eligibility manifest
532
+ env:
533
+ WEEK: ${{ steps.analysis-context.outputs.week }}
534
+ RUN_ID: ${{ github.run_id }}
535
+ CURRENT_DATETIME: ${{ steps.analysis-context.outputs.current_datetime }}
536
+ CANDIDATE_SUMMARY: ${{ steps.analysis-context.outputs.candidate_output_file }}
537
+ PUBLISHED_SUMMARY: ${{ steps.analysis-context.outputs.published_output_file }}
538
+ RAW_JSON_FILE: ${{ steps.analysis-context.outputs.week_file }}
539
+ ANALYSIS_SOURCE: ${{ steps.run-analysis.outputs.analysis_source }}
540
+ ANALYSIS_MODEL: ${{ steps.run-analysis.outputs.analysis_model }}
541
+ MANIFEST_FILE: ${{ steps.analysis-context.outputs.publish_manifest_file }}
542
+ run: |
543
+ set -euo pipefail
544
+ ARTIFACT_ARGS=()
545
+ for candidate in \
546
+ "external_news=data/raw/${WEEK}-external-news.json" \
547
+ "techcrunch_news=data/raw/${WEEK}-techcrunch.json" \
548
+ "correlations=data/analyzed/${WEEK}-correlations.json" \
549
+ "press_context=data/analyzed/${WEEK}-press-context.md"
550
+ do
551
+ path="${candidate#*=}"
552
+ [ -f "$path" ] && ARTIFACT_ARGS+=(--artifact "$candidate")
553
+ done
554
+ python3 scripts/publish_manifest.py create \
555
+ --week "$WEEK" \
556
+ --run-id "$RUN_ID" \
557
+ --current-datetime "$CURRENT_DATETIME" \
558
+ --summary "$CANDIDATE_SUMMARY" \
559
+ --published-summary "$PUBLISHED_SUMMARY" \
560
+ --raw-json "$RAW_JSON_FILE" \
561
+ --analysis-source "$ANALYSIS_SOURCE" \
562
+ --analysis-model "$ANALYSIS_MODEL" \
563
+ --validation-status passed \
564
+ --output "$MANIFEST_FILE" \
565
+ "${ARTIFACT_ARGS[@]}"
566
+
567
+ - name: Assert candidate is eligible for promotion
568
+ run: python3 scripts/publish_manifest.py assert-eligible --manifest "${{ steps.analysis-context.outputs.publish_manifest_file }}"
569
+
570
- name: Commit analysis and learnings to data branch
571
env:
572
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
573
DATA_BRANCH: publish
574
WEEK: ${{ steps.analysis-context.outputs.week }}
575
+ CANDIDATE_SUMMARY: ${{ steps.analysis-context.outputs.candidate_output_file }}
576
+ PUBLISHED_SUMMARY: ${{ steps.analysis-context.outputs.published_output_file }}
577
+ MANIFEST_FILE: ${{ steps.analysis-context.outputs.publish_manifest_file }}
578
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
579
run: |
580
set -euo pipefail
581
+ python3 scripts/publish_manifest.py assert-eligible --manifest "$MANIFEST_FILE"
582
+ cp scripts/publish_manifest.py publish-manifest-tool.py
583
git config user.name "github-actions[bot]"
584
git config user.email "github-actions[bot]@users.noreply.github.com"
585
# Check for analysis data OR squad learning state changes
535
- if ! git status --short -- data/analyzed data/metrics .squad | grep -q .; then
586
+ if ! git status --short -- data/analyzed data/candidates data/metrics .squad | grep -q .; then
587
echo "No analyzed data, token usage, or learning state changes to commit."
588
+ rm -f publish-manifest-tool.py
589
exit 0
590
fi
591
+ mkdir -p "$(dirname "$PUBLISHED_SUMMARY")"
592
+ cp "$CANDIDATE_SUMMARY" "$PUBLISHED_SUMMARY"
593
cp -r data/analyzed analyzed-data-backup
594
+ cp -r data/candidates candidates-data-backup
595
cp -r data/metrics metrics-data-backup
596
# Preserve any .squad changes written by the agent (learnings, skills)
597
if git status --short -- .squad | grep -q .; then
@@ -552,32 +607,43 @@ jobs:
607
git fetch origin "$DEFAULT_BRANCH"
608
git checkout -f -B "$DATA_BRANCH" "origin/$DEFAULT_BRANCH"
609
fi
555
- mkdir -p data/analyzed data/metrics
610
+ mkdir -p data/analyzed data/candidates data/metrics
611
+ python3 publish-manifest-tool.py assert-eligible --manifest "candidates-data-backup/${WEEK}/${GITHUB_RUN_ID}/publish-manifest.json"
612
# Only copy current week's analysis files — do not overwrite prior weeks
557
- # Required: current week's summary must exist or the pipeline is broken
613
+ # Required: current week's promoted summary must be backed by an eligible manifest
614
cp "analyzed-data-backup/${WEEK}-summary.md" data/analyzed/
615
+ cp -r "candidates-data-backup/${WEEK}" data/candidates/
616
# Optional sidecars: conditionally generated
617
cp "analyzed-data-backup/${WEEK}-correlations.json" data/analyzed/ 2>/dev/null || true
618
cp "analyzed-data-backup/${WEEK}-press-context.md" data/analyzed/ 2>/dev/null || true
619
cp -r metrics-data-backup/* data/metrics/ 2>/dev/null || true
563
- rm -rf analyzed-data-backup metrics-data-backup
620
+ rm -rf analyzed-data-backup candidates-data-backup metrics-data-backup publish-manifest-tool.py
621
# Restore squad learning state
622
if [ "$HAS_LEARNINGS" = "true" ]; then
623
cp -r squad-learning-backup/* .squad/ 2>/dev/null || true
624
rm -rf squad-learning-backup
625
fi
569
- git add data/analyzed/ data/metrics/ .squad/
626
+ git add data/analyzed/ data/candidates/ data/metrics/ .squad/
627
git diff --cached --quiet && exit 0
628
git commit -m "analysis: weekly summary + learnings $WEEK [run #${GITHUB_RUN_ID}]"
629
git push origin "$DATA_BRANCH"
630
631
- name: Upload analyzed data
632
+ if: always()
633
uses: actions/upload-artifact@v4
634
with:
635
name: analyzed-data
636
path: data/analyzed/
637
if-no-files-found: warn
638
639
+ - name: Upload analysis candidate
640
+ if: always()
641
+ uses: actions/upload-artifact@v4
642
+ with:
643
+ name: analysis-candidate
644
+ path: data/candidates/
645
+ if-no-files-found: warn
646
+
647
generate:
648
needs: analyze
649
runs-on: ubuntu-latest
@@ -600,6 +666,12 @@ jobs:
666
name: analyzed-data
667
path: data/analyzed/
668
669
+ - name: Download analysis candidate artifact
670
+ uses: actions/download-artifact@v4
671
+ with:
672
+ name: analysis-candidate
673
+ path: data/candidates/
674
+
675
- name: Set up Python
676
uses: actions/setup-python@v5
677
with:
@@ -612,8 +684,10 @@ jobs:
684
id: generate-content
685
env:
686
SUMMARY_FILE: ${{ needs.analyze.outputs.summary_file }}
687
+ MANIFEST_FILE: ${{ needs.analyze.outputs.publish_manifest_file }}
688
run: |
689
set -euo pipefail
690
+ python3 scripts/publish_manifest.py assert-eligible --manifest "$MANIFEST_FILE"
691
python3 - <<'PYGEN' "$SUMMARY_FILE" >> "$GITHUB_OUTPUT"
692
import sys
693
from pathlib import Path
@@ -628,8 +702,10 @@ jobs:
702
- name: Hydrate analyzed data from publish
703
env:
704
WEEK: ${{ needs.analyze.outputs.week }}
705
+ MANIFEST_FILE: ${{ needs.analyze.outputs.publish_manifest_file }}
706
run: |
707
set -euo pipefail
708
+ python3 scripts/publish_manifest.py assert-eligible --manifest "$MANIFEST_FILE"
709
git fetch origin publish
710
# Restore prior weeks' analyzed files from publish so generate_rollups.py
711
# sees accurate historical data. Skip the current week's file (already
@@ -650,9 +726,11 @@ jobs:
726
DATA_BRANCH: publish
727
WEEK: ${{ needs.analyze.outputs.week }}
728
PAGE_PATH: ${{ steps.generate-content.outputs.page_path }}
729
+ MANIFEST_FILE: ${{ needs.analyze.outputs.publish_manifest_file }}
730
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
731
run: |
732
set -euo pipefail
733
+ python3 scripts/publish_manifest.py assert-eligible --manifest "$MANIFEST_FILE"
734
git config user.name "github-actions[bot]"
735
git config user.email "github-actions[bot]@users.noreply.github.com"
736
if ! git status --short -- content/weekly content/monthly content/yearly | grep -q .; then
@@ -800,11 +878,18 @@ jobs:
878
name: analyzed-data
879
path: data/analyzed/
880
881
+ - uses: actions/download-artifact@v4
882
+ with:
883
+ name: analysis-candidate
884
+ path: data/candidates/
885
+
886
- name: Create GitHub Release
887
env:
888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
889
SUMMARY_FILE: ${{ needs.analyze.outputs.summary_file }}
890
+ MANIFEST_FILE: ${{ needs.analyze.outputs.publish_manifest_file }}
891
run: |
892
+ python3 scripts/publish_manifest.py assert-eligible --manifest "$MANIFEST_FILE"
893
WEEK=$(basename "$SUMMARY_FILE" | sed 's/-summary.md//')
894
TAG="week-${WEEK}"
895
TITLE="Week ${WEEK} — Tech Trends Summary"
.squad/agents/bender/history.md
+1
@@ -49,3 +49,4 @@
49
- Matrixing RSS is an isolation feature at current scale, not a speed feature; matrixing GitHub needs a shard experiment because search quota and secondary limits are shared across jobs.
50
- Recommended PRD path is hybrid staged fan-out/fan-in: establish validated artifact contracts first, then gate RSS matrix, GitHub query matrix, and analysis map/reduce on measured thresholds.
51
- Run 27030646485 also showed analysis, not crawling, is the critical-path risk: three Copilot attempts consumed ~28m41s, failed quality gates, GitHub Models had no `openai/gpt-4o` access, and the workflow shipped via no-AI fallback with ~112.9k estimated input tokens.
52
+- Issue #249 implementation: weekly analysis now writes to `data/candidates/<week>/<run_id>/` first and emits a `publish_eligibility_v1` manifest before any `data/analyzed/<week>-summary.md` promotion; promotion must fail closed on no-AI, stale source evidence, missing checksums, or failed validation.
scripts/publish_manifest.py
new
+259
@@ -0,0 +1,259 @@
1
+#!/usr/bin/env python3
2
+from __future__ import annotations
3
+
4
+import argparse
5
+import hashlib
6
+import json
7
+from datetime import UTC, datetime
8
+from pathlib import Path
9
+from typing import Any
10
+
11
+
12
+SCHEMA_VERSION = "publish_eligibility_v1"
13
+AI_SOURCES = {"copilot-cli", "github-models"}
14
+
15
+
16
+def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
17
+ parser = argparse.ArgumentParser(description="Create or validate a weekly publish eligibility manifest.")
18
+ subparsers = parser.add_subparsers(dest="command", required=True)
19
+
20
+ create = subparsers.add_parser("create", help="Create a publish eligibility manifest.")
21
+ create.add_argument("--week", required=True)
22
+ create.add_argument("--run-id", required=True)
23
+ create.add_argument("--current-datetime", required=True)
24
+ create.add_argument("--summary", required=True, type=Path)
25
+ create.add_argument("--published-summary", required=True, type=Path)
26
+ create.add_argument("--raw-json", required=True, type=Path)
27
+ create.add_argument("--analysis-source", required=True)
28
+ create.add_argument("--analysis-model", required=True)
29
+ create.add_argument("--validation-status", choices=["passed", "failed"], required=True)
30
+ create.add_argument("--output", required=True, type=Path)
31
+ create.add_argument("--artifact", action="append", default=[], help="Additional source artifact as role=path.")
32
+
33
+ check = subparsers.add_parser("assert-eligible", help="Fail unless the manifest permits promotion.")
34
+ check.add_argument("--manifest", required=True, type=Path)
35
+ return parser.parse_args(argv)
36
+
37
+
38
+def sha256_file(path: Path) -> str | None:
39
+ if not path.exists() or not path.is_file():
40
+ return None
41
+ digest = hashlib.sha256()
42
+ with path.open("rb") as handle:
43
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
44
+ digest.update(chunk)
45
+ return digest.hexdigest()
46
+
47
+
48
+def parse_datetime(value: Any) -> datetime | None:
49
+ if not isinstance(value, str) or not value.strip():
50
+ return None
51
+ candidate = value.strip()
52
+ if candidate.endswith("Z"):
53
+ candidate = f"{candidate[:-1]}+00:00"
54
+ try:
55
+ parsed = datetime.fromisoformat(candidate)
56
+ except ValueError:
57
+ return None
58
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
59
+
60
+
61
+def week_slug(value: datetime) -> str:
62
+ iso_year, iso_week, _ = value.astimezone(UTC).isocalendar()
63
+ return f"{iso_year}-W{iso_week:02d}"
64
+
65
+
66
+def load_json(path: Path) -> dict[str, Any] | None:
67
+ if not path.exists():
68
+ return None
69
+ try:
70
+ payload = json.loads(path.read_text(encoding="utf-8"))
71
+ except (OSError, json.JSONDecodeError):
72
+ return None
73
+ return payload if isinstance(payload, dict) else None
74
+
75
+
76
+def same_day_reuse_status(payload: dict[str, Any] | None) -> dict[str, Any]:
77
+ metadata = payload.get("metadata", {}) if isinstance(payload, dict) else {}
78
+ if not isinstance(metadata, dict):
79
+ metadata = {}
80
+ explicit = metadata.get("same_day_reuse") or metadata.get("same_day_reuse_status")
81
+ if explicit:
82
+ return {"status": str(explicit), "source": "artifact-metadata"}
83
+ return {
84
+ "status": "not_reused",
85
+ "source": "default",
86
+ "details": "No same-day reuse marker was present on this artifact.",
87
+ }
88
+
89
+
90
+def freshness_for_json_artifact(role: str, week: str, payload: dict[str, Any] | None) -> dict[str, Any]:
91
+ if payload is None:
92
+ return {"status": "missing" if role == "raw_github" else "not_applicable", "reasons": ["artifact missing"]}
93
+
94
+ reasons: list[str] = []
95
+ artifact_week = payload.get("week")
96
+ if artifact_week != week:
97
+ reasons.append(f"week mismatch: expected {week}, found {artifact_week!r}")
98
+
99
+ timestamp = payload.get("crawled_at") or payload.get("generated_at")
100
+ parsed = parse_datetime(timestamp)
101
+ if role in {"raw_github", "external_news", "techcrunch_news"}:
102
+ if parsed is None:
103
+ reasons.append("missing or invalid crawled_at/generated_at timestamp")
104
+ elif week_slug(parsed) != week:
105
+ reasons.append(f"timestamp week mismatch: expected {week}, found {week_slug(parsed)}")
106
+
107
+ crawl_window = payload.get("crawl_window")
108
+ if role in {"external_news", "techcrunch_news"} and isinstance(crawl_window, dict):
109
+ until = parse_datetime(crawl_window.get("until"))
110
+ if until is not None and week_slug(until) != week:
111
+ reasons.append(f"crawl_window.until week mismatch: expected {week}, found {week_slug(until)}")
112
+
113
+ return {"status": "fresh" if not reasons else "stale", "reasons": reasons}
114
+
115
+
116
+def artifact_entry(role: str, path: Path, week: str) -> dict[str, Any]:
117
+ payload = load_json(path) if path.suffix == ".json" else None
118
+ metadata = payload.get("metadata", {}) if isinstance(payload, dict) and isinstance(payload.get("metadata"), dict) else {}
119
+ entry: dict[str, Any] = {
120
+ "role": role,
121
+ "path": path.as_posix(),
122
+ "exists": path.exists(),
123
+ "size_bytes": path.stat().st_size if path.exists() else 0,
124
+ "sha256": sha256_file(path),
125
+ "artifact_checksum": metadata.get("artifact_checksum"),
126
+ "week": payload.get("week") if isinstance(payload, dict) else None,
127
+ "crawled_at": payload.get("crawled_at") if isinstance(payload, dict) else None,
128
+ "same_day_reuse": same_day_reuse_status(payload),
129
+ "freshness": freshness_for_json_artifact(role, week, payload) if path.suffix == ".json" else {"status": "not_applicable", "reasons": []},
130
+ }
131
+ if "source_status" in metadata:
132
+ entry["source_status"] = metadata["source_status"]
133
+ if "sources_requested" in metadata:
134
+ entry["sources_requested"] = metadata["sources_requested"]
135
+ entry["sources_succeeded"] = metadata.get("sources_succeeded", [])
136
+ entry["sources_failed"] = metadata.get("sources_failed", [])
137
+ return entry
138
+
139
+
140
+def parse_artifacts(values: list[str]) -> list[tuple[str, Path]]:
141
+ artifacts: list[tuple[str, Path]] = []
142
+ for value in values:
143
+ if "=" not in value:
144
+ raise SystemExit(f"Invalid --artifact value {value!r}; expected role=path")
145
+ role, raw_path = value.split("=", 1)
146
+ role = role.strip()
147
+ if not role:
148
+ raise SystemExit(f"Invalid --artifact value {value!r}; missing role")
149
+ artifacts.append((role, Path(raw_path)))
150
+ return artifacts
151
+
152
+
153
+def create_manifest(args: argparse.Namespace) -> int:
154
+ artifacts = [("raw_github", args.raw_json), *parse_artifacts(args.artifact)]
155
+ source_artifacts = [artifact_entry(role, path, args.week) for role, path in artifacts if path.exists() or role == "raw_github"]
156
+ artifact_reasons = [
157
+ f"{entry['role']}: {reason}"
158
+ for entry in source_artifacts
159
+ for reason in entry.get("freshness", {}).get("reasons", [])
160
+ ]
161
+
162
+ analysis_source = args.analysis_source.strip()
163
+ ai_status = "ai" if analysis_source in AI_SOURCES else "no-ai" if analysis_source == "no-ai" else "unknown"
164
+ candidate_exists = args.summary.exists()
165
+ validation_passed = args.validation_status == "passed"
166
+ eligible = candidate_exists and validation_passed and ai_status == "ai" and not artifact_reasons
167
+
168
+ reasons: list[str] = []
169
+ if not candidate_exists:
170
+ reasons.append(f"candidate summary missing: {args.summary}")
171
+ if not validation_passed:
172
+ reasons.append("analysis validation did not pass")
173
+ if ai_status != "ai":
174
+ reasons.append(f"analysis source is not AI-publishable: {analysis_source or 'unknown'}")
175
+ reasons.extend(artifact_reasons)
176
+
177
+ manifest = {
178
+ "schema_version": SCHEMA_VERSION,
179
+ "run_id": args.run_id,
180
+ "week": args.week,
181
+ "generated_at": args.current_datetime,
182
+ "candidate": {
183
+ "summary_path": args.summary.as_posix(),
184
+ "published_summary_path": args.published_summary.as_posix(),
185
+ "summary_sha256": sha256_file(args.summary),
186
+ },
187
+ "source_artifacts": source_artifacts,
188
+ "analysis": {
189
+ "ai_status": ai_status,
190
+ "source": analysis_source,
191
+ "model": args.analysis_model,
192
+ "provenance": {
193
+ "run_id": args.run_id,
194
+ "current_datetime": args.current_datetime,
195
+ },
196
+ },
197
+ "validation": {
198
+ "status": args.validation_status,
199
+ "quality_gates": [
200
+ {
201
+ "name": "analysis_gate",
202
+ "status": args.validation_status,
203
+ "source": analysis_source,
204
+ }
205
+ ],
206
+ },
207
+ "promotion": {
208
+ "eligible": eligible,
209
+ "decision": "promote" if eligible else "block",
210
+ "reasons": reasons,
211
+ },
212
+ }
213
+
214
+ args.output.parent.mkdir(parents=True, exist_ok=True)
215
+ args.output.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
216
+ print(f"Publish manifest decision={manifest['promotion']['decision']} path={args.output}")
217
+ if reasons:
218
+ for reason in reasons:
219
+ print(f"- {reason}")
220
+ return 0
221
+
222
+
223
+def assert_eligible(args: argparse.Namespace) -> int:
224
+ payload = load_json(args.manifest)
225
+ if payload is None:
226
+ raise SystemExit(f"Publish manifest is missing or malformed: {args.manifest}")
227
+ if payload.get("schema_version") != SCHEMA_VERSION:
228
+ raise SystemExit(f"Unsupported publish manifest schema: {payload.get('schema_version')!r}")
229
+ promotion = payload.get("promotion")
230
+ if not isinstance(promotion, dict) or promotion.get("eligible") is not True or promotion.get("decision") != "promote":
231
+ reasons = promotion.get("reasons") if isinstance(promotion, dict) else ["missing promotion block"]
232
+ raise SystemExit(f"Manifest blocks promotion: {', '.join(str(reason) for reason in reasons)}")
233
+ candidate = payload.get("candidate")
234
+ if not isinstance(candidate, dict) or not candidate.get("summary_sha256"):
235
+ raise SystemExit("Manifest lacks candidate summary checksum.")
236
+ source_artifacts = payload.get("source_artifacts")
237
+ if not isinstance(source_artifacts, list) or not source_artifacts:
238
+ raise SystemExit("Manifest lacks source artifact provenance.")
239
+ for entry in source_artifacts:
240
+ if not isinstance(entry, dict) or not entry.get("sha256"):
241
+ raise SystemExit("Manifest source artifact is missing a checksum.")
242
+ freshness = entry.get("freshness", {})
243
+ if isinstance(freshness, dict) and freshness.get("status") == "stale":
244
+ raise SystemExit(f"Manifest source artifact is stale: {entry.get('path')}")
245
+ print(f"Manifest permits promotion: {args.manifest}")
246
+ return 0
247
+
248
+
249
+def main(argv: list[str] | None = None) -> int:
250
+ args = parse_args(argv)
251
+ if args.command == "create":
252
+ return create_manifest(args)
253
+ if args.command == "assert-eligible":
254
+ return assert_eligible(args)
255
+ raise AssertionError(args.command)
256
+
257
+
258
+if __name__ == "__main__":
259
+ raise SystemExit(main())
tests/test_pipeline.py
+51
@@ -319,6 +319,57 @@ class WorkflowConfigTests(unittest.TestCase):
319
self.assertIn("📊 **SquadScope Week", webhook_run)
320
self.assertIn("Webhook post failed (non-critical)", webhook_run)
321
322
+ def test_publish_workflow_uses_candidate_manifest_before_promotion(self) -> None:
323
+ workflow_path = Path(".github/workflows/crawl-and-publish.yml")
324
+ workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))
325
+
326
+ analyze = workflow["jobs"]["analyze"]
327
+ self.assertEqual(analyze["outputs"]["summary_file"], "${{ steps.analysis-context.outputs.published_output_file }}")
328
+ self.assertEqual(
329
+ analyze["outputs"]["candidate_summary_file"],
330
+ "${{ steps.analysis-context.outputs.candidate_output_file }}",
331
+ )
332
+ self.assertEqual(
333
+ analyze["outputs"]["publish_manifest_file"],
334
+ "${{ steps.analysis-context.outputs.publish_manifest_file }}",
335
+ )
336
+
337
+ prepare_step = next((s for s in analyze["steps"] if s.get("name") == "Prepare analysis context"), None)
338
+ self.assertIsNotNone(prepare_step)
339
+ prepare_run = prepare_step["run"]
340
+ self.assertIn("data/candidates", prepare_run)
341
+ self.assertIn("candidate_output_file", prepare_run)
342
+ self.assertIn("publish_manifest_file", prepare_run)
343
+ self.assertIn("published_output_file=data/analyzed", prepare_run)
344
+
345
+ manifest_step = next((s for s in analyze["steps"] if s.get("name") == "Emit publish eligibility manifest"), None)
346
+ self.assertIsNotNone(manifest_step)
347
+ manifest_run = manifest_step["run"]
348
+ self.assertIn("scripts/publish_manifest.py create", manifest_run)
349
+ self.assertIn("--analysis-source", manifest_run)
350
+ self.assertIn("--analysis-model", manifest_run)
351
+ self.assertIn("--validation-status passed", manifest_run)
352
+
353
+ assert_step = next((s for s in analyze["steps"] if s.get("name") == "Assert candidate is eligible for promotion"), None)
354
+ self.assertIsNotNone(assert_step)
355
+ self.assertIn("scripts/publish_manifest.py assert-eligible", assert_step["run"])
356
+
357
+ commit_step = next((s for s in analyze["steps"] if s.get("name") == "Commit analysis and learnings to data branch"), None)
358
+ self.assertIsNotNone(commit_step)
359
+ commit_run = commit_step["run"]
360
+ self.assertIn('assert-eligible --manifest "$MANIFEST_FILE"', commit_run)
361
+ self.assertIn('cp "$CANDIDATE_SUMMARY" "$PUBLISHED_SUMMARY"', commit_run)
362
+ self.assertIn("git add data/analyzed/ data/candidates/", commit_run)
363
+
364
+ upload_candidate = next((s for s in analyze["steps"] if s.get("name") == "Upload analysis candidate"), None)
365
+ self.assertIsNotNone(upload_candidate)
366
+ self.assertEqual(upload_candidate["if"], "always()")
367
+
368
+ generate = workflow["jobs"]["generate"]
369
+ generate_step = next((s for s in generate["steps"] if s.get("name") == "Generate weekly content"), None)
370
+ self.assertIsNotNone(generate_step)
371
+ self.assertIn('assert-eligible --manifest "$MANIFEST_FILE"', generate_step["run"])
372
+
373
def test_notify_failure_job_creates_or_updates_issue(self) -> None:
374
workflow_path = Path(".github/workflows/crawl-and-publish.yml")
375
workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))
tests/test_publish_manifest.py
new
+167
@@ -0,0 +1,167 @@
1
+import json
2
+import tempfile
3
+import unittest
4
+from pathlib import Path
5
+
6
+import scripts.publish_manifest as publish_manifest
7
+
8
+
9
+RUN_ID = "123456"
10
+CURRENT_DATETIME = "2026-05-18T08:00:00Z"
11
+WEEK = "2026-W21"
12
+
13
+
14
+def write_raw(path: Path, *, week: str = WEEK, crawled_at: str = CURRENT_DATETIME) -> None:
15
+ path.parent.mkdir(parents=True, exist_ok=True)
16
+ path.write_text(
17
+ json.dumps(
18
+ {
19
+ "week": week,
20
+ "crawled_at": crawled_at,
21
+ "new_repos": [],
22
+ "trending_repos": [],
23
+ "metadata": {"same_day_reuse": "not_reused"},
24
+ }
25
+ ),
26
+ encoding="utf-8",
27
+ )
28
+
29
+
30
+def write_summary(path: Path) -> None:
31
+ path.parent.mkdir(parents=True, exist_ok=True)
32
+ path.write_text("---\nweek: 2026-W21\n---\n\nbody\n", encoding="utf-8")
33
+
34
+
35
+class PublishManifestTests(unittest.TestCase):
36
+ def test_ai_candidate_with_fresh_sources_is_eligible(self) -> None:
37
+ tests_root = Path(__file__).resolve().parent
38
+ with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
39
+ base = Path(tmpdir)
40
+ raw = base / "data/raw/2026-W21.json"
41
+ summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
42
+ manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
43
+ write_raw(raw)
44
+ write_summary(summary)
45
+
46
+ exit_code = publish_manifest.main(
47
+ [
48
+ "create",
49
+ "--week",
50
+ WEEK,
51
+ "--run-id",
52
+ RUN_ID,
53
+ "--current-datetime",
54
+ CURRENT_DATETIME,
55
+ "--summary",
56
+ str(summary),
57
+ "--published-summary",
58
+ str(base / "data/analyzed/2026-W21-summary.md"),
59
+ "--raw-json",
60
+ str(raw),
61
+ "--analysis-source",
62
+ "copilot-cli",
63
+ "--analysis-model",
64
+ "copilot-default",
65
+ "--validation-status",
66
+ "passed",
67
+ "--output",
68
+ str(manifest),
69
+ ]
70
+ )
71
+
72
+ self.assertEqual(exit_code, 0)
73
+ payload = json.loads(manifest.read_text(encoding="utf-8"))
74
+ self.assertEqual(payload["schema_version"], "publish_eligibility_v1")
75
+ self.assertEqual(payload["analysis"]["ai_status"], "ai")
76
+ self.assertTrue(payload["promotion"]["eligible"])
77
+ self.assertEqual(payload["promotion"]["decision"], "promote")
78
+ self.assertRegex(payload["candidate"]["summary_sha256"], r"^[0-9a-f]{64}$")
79
+ self.assertRegex(payload["source_artifacts"][0]["sha256"], r"^[0-9a-f]{64}$")
80
+ self.assertEqual(publish_manifest.main(["assert-eligible", "--manifest", str(manifest)]), 0)
81
+
82
+ def test_no_ai_candidate_is_not_eligible(self) -> None:
83
+ tests_root = Path(__file__).resolve().parent
84
+ with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
85
+ base = Path(tmpdir)
86
+ raw = base / "data/raw/2026-W21.json"
87
+ summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
88
+ manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
89
+ write_raw(raw)
90
+ write_summary(summary)
91
+
92
+ publish_manifest.main(
93
+ [
94
+ "create",
95
+ "--week",
96
+ WEEK,
97
+ "--run-id",
98
+ RUN_ID,
99
+ "--current-datetime",
100
+ CURRENT_DATETIME,
101
+ "--summary",
102
+ str(summary),
103
+ "--published-summary",
104
+ str(base / "data/analyzed/2026-W21-summary.md"),
105
+ "--raw-json",
106
+ str(raw),
107
+ "--analysis-source",
108
+ "no-ai",
109
+ "--analysis-model",
110
+ "none",
111
+ "--validation-status",
112
+ "passed",
113
+ "--output",
114
+ str(manifest),
115
+ ]
116
+ )
117
+
118
+ payload = json.loads(manifest.read_text(encoding="utf-8"))
119
+ self.assertFalse(payload["promotion"]["eligible"])
120
+ self.assertIn("analysis source is not AI-publishable", payload["promotion"]["reasons"][0])
121
+ with self.assertRaises(SystemExit):
122
+ publish_manifest.main(["assert-eligible", "--manifest", str(manifest)])
123
+
124
+ def test_stale_source_artifact_blocks_promotion(self) -> None:
125
+ tests_root = Path(__file__).resolve().parent
126
+ with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
127
+ base = Path(tmpdir)
128
+ raw = base / "data/raw/2026-W21.json"
129
+ summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
130
+ manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
131
+ write_raw(raw, crawled_at="2026-05-11T08:00:00Z")
132
+ write_summary(summary)
133
+
134
+ publish_manifest.main(
135
+ [
136
+ "create",
137
+ "--week",
138
+ WEEK,
139
+ "--run-id",
140
+ RUN_ID,
141
+ "--current-datetime",
142
+ CURRENT_DATETIME,
143
+ "--summary",
144
+ str(summary),
145
+ "--published-summary",
146
+ str(base / "data/analyzed/2026-W21-summary.md"),
147
+ "--raw-json",
148
+ str(raw),
149
+ "--analysis-source",
150
+ "github-models",
151
+ "--analysis-model",
152
+ "openai/gpt-4o",
153
+ "--validation-status",
154
+ "passed",
155
+ "--output",
156
+ str(manifest),
157
+ ]
158
+ )
159
+
160
+ payload = json.loads(manifest.read_text(encoding="utf-8"))
161
+ self.assertFalse(payload["promotion"]["eligible"])
162
+ self.assertEqual(payload["source_artifacts"][0]["freshness"]["status"], "stale")
163
+ self.assertTrue(any("timestamp week mismatch" in reason for reason in payload["promotion"]["reasons"]))
164
+
165
+
166
+if __name__ == "__main__":
167
+ unittest.main()