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()