Validate automated weekly run and enable cron schedule (#36)

* Validate cron pipeline and docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(workflow): Address Copilot review comments on PR #35 - Fix mtime-based release file selection: use needs.analyze.outputs.summary_file instead of 'ls -t' to avoid picking wrong file when artifacts get extracted with different mtimes - Add pages concurrency group to deploy job: prevent race conditions with deploy-site.yml workflow by sharing the same 'pages' concurrency group Resolves Copilot review comments ID:3258686126 and ID:3258686164 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <copilot@github.com>

Juan Manuel Servera committed May 18, 2026 at 14:10 UTC 5ffe3cd1ee723cc3b46b912b6c6992f6038bd4bd
5 files changed +498 -15
.github/workflows/crawl-and-publish.yml
+13 -13
@@ -4,6 +4,12 @@ on:
4 schedule:
5 - cron: '0 8 * * 1'
6 workflow_dispatch:
7 + inputs:
8 + publish_release:
9 + description: 'Create or update the weekly GitHub Release during a manual run.'
10 + required: false
11 + default: false
12 + type: boolean
13
14 permissions:
15 actions: read
@@ -311,7 +317,7 @@ jobs:
317 SUMMARY_FILE: ${{ needs.analyze.outputs.summary_file }}
318 run: |
319 set -euo pipefail
314 - python3 - <<'PY2' "$SUMMARY_FILE" >> "$GITHUB_OUTPUT"
320 + python3 - <<'PYGEN' "$SUMMARY_FILE" >> "$GITHUB_OUTPUT"
321 import sys
322 from pathlib import Path
323
@@ -320,7 +326,7 @@ jobs:
326 summary_path = Path(sys.argv[1])
327 page_path = generate_content.generate_content(summary_path)
328 print(f"page_path={page_path.as_posix()}")
323 - PY2
329 + PYGEN
330
331 - name: Commit generated content
332 env:
@@ -355,7 +361,7 @@ jobs:
361 if-no-files-found: warn
362
363 deploy:
358 - needs: generate
364 + needs: [crawl, analyze, generate]
365 runs-on: ubuntu-latest
366 permissions:
367 actions: read
@@ -391,12 +397,8 @@ jobs:
397 name: analyzed-data
398 path: data/analyzed/
399
394 - - name: Download generated content artifact
395 - uses: actions/download-artifact@v4
396 - with:
397 - name: generated-content
398 - path: content/weekly/
399 -
400 + # The generate job already pushes weekly content to the default branch.
401 + # Deploy builds from that branch state to avoid artifact extraction conflicts.
402 - name: Configure GitHub Pages
403 uses: actions/configure-pages@v5
404
@@ -428,6 +430,7 @@ jobs:
430 uses: actions/deploy-pages@v4
431
432 notify:
433 + if: github.event_name == 'schedule' || github.event.inputs.publish_release == 'true'
434 needs: [generate, deploy]
435 runs-on: ubuntu-latest
436 permissions:
@@ -448,7 +451,4 @@ jobs:
451 run: |
452 WEEK=$(basename "$SUMMARY_FILE" | sed 's/-summary.md//')
453
451 - gh release create "week-${WEEK}" \
452 - --title "Week ${WEEK} — Tech Trends Summary" \
453 - --notes-file "$SUMMARY_FILE" \
454 - --latest
454 + gh release create "week-${WEEK}" --title "Week ${WEEK} — Tech Trends Summary" --notes-file "$SUMMARY_FILE" --latest
.github/workflows/deploy-site.yml
+1
@@ -17,6 +17,7 @@ concurrency:
17
18 jobs:
19 build:
20 + if: github.actor != 'github-actions[bot]'
21 runs-on: ubuntu-latest
22 env:
23 HUGO_VERSION: 0.161.1
README.md
+29 -2
@@ -34,8 +34,35 @@ SquadScope is a Hugo-powered GitHub Pages site for weekly, monthly, and yearly t
34 - `data/snapshots/YYYY-WNN-stars.json` intentionally stores the broader pre-filter search candidate universe to preserve week-over-week `stars_gained` comparisons even when a repo is later filtered out of the published payload.
35 - Live runs use open-ended `created:>` / `pushed:>` GitHub search filters; `--as-of` runs switch to bounded date ranges so historical backfills stay deterministic.
36
37 +## Automated weekly pipeline
38 +
39 +`.github/workflows/crawl-and-publish.yml` runs the full weekly automation chain:
40 +
41 +1. `crawl` writes `data/raw/YYYY-WNN.json` and `data/snapshots/YYYY-WNN-stars.json`.
42 +2. `analyze` turns the raw payload into `data/analyzed/YYYY-WNN-summary.md`.
43 +3. `generate` converts the analysis into `content/weekly/YYYY/WNN.md`.
44 +4. `deploy` builds `public/` with Hugo and publishes the site to GitHub Pages.
45 +
46 +### Schedule and manual runs
47 +
48 +- Scheduled cron: `0 8 * * 1` (`Monday 08:00 UTC`)
49 +- Manual workflow trigger: GitHub Actions UI or `gh workflow run crawl-and-publish.yml`
50 +
51 +### Required secrets
52 +
53 +- `COPILOT_GH_TOKEN` for the primary Copilot CLI analysis path
54 +- `GITHUB_TOKEN` for crawling, fallback analysis, commits, and Pages deployment
55 +
56 +### Local/manual stage commands
57 +
58 +- Crawl: `python3 scripts/crawl.py --as-of YYYY-MM-DD`
59 +- Analyze fallback: `python3 scripts/analyze_fallback.py --raw-json data/raw/YYYY-WNN.json --output data/analyzed/YYYY-WNN-summary.md --current-datetime YYYY-MM-DDTHH:MM:SSZ`
60 +- Gate: `python3 scripts/analysis_gate.py --analysis-file data/analyzed/YYYY-WNN-summary.md --raw-json data/raw/YYYY-WNN.json --current-datetime YYYY-MM-DDTHH:MM:SSZ`
61 +- Generate: `python3 scripts/generate_content.py data/analyzed/YYYY-WNN-summary.md`
62 +- Build: `hugo --minify`
63 +
64 ## Deployment
65
39 -Pushing to `main` triggers `.github/workflows/deploy-site.yml`, which builds the Hugo site and deploys the generated `public/` directory to GitHub Pages.
66 +Direct pushes to `main` still trigger `.github/workflows/deploy-site.yml` for standard site deploys. The weekly automation deploys Pages from `crawl-and-publish.yml` and `deploy-site.yml` skips bot-authored pushes from that workflow to avoid duplicate Pages runs.
67
41 -The scheduled weekly pipeline in `.github/workflows/crawl-and-publish.yml` now runs `crawl → analyze → generate → deploy`, using `scripts/generate_content.py` to turn `data/analyzed/YYYY-WNN-summary.md` into `content/weekly/YYYY/WNN.md` before the Pages build and Pagefind indexing steps.
68 +See `docs/pipeline-validation.md` for the stage checklist, artifact handoffs, success criteria, and known limitations.
docs/pipeline-validation.md new
+131
@@ -0,0 +1,131 @@
1 +# Pipeline Validation Checklist
2 +
3 +This checklist validates the automated weekly workflow in `.github/workflows/crawl-and-publish.yml`.
4 +
5 +## Trigger and scheduling
6 +
7 +- [x] `schedule` is enabled in the workflow.
8 +- [x] Cron is `0 8 * * 1`, which runs every Monday at 08:00 UTC.
9 +- [x] `workflow_dispatch` is enabled for manual runs from the Actions tab or `gh workflow run crawl-and-publish.yml`.
10 +- [x] `concurrency.group` is `weekly-crawl` with `cancel-in-progress: false`, so a second run waits instead of overlapping the active run.
11 +
12 +## Secrets and tokens
13 +
14 +Required secrets/tokens:
15 +
16 +- `COPILOT_GH_TOKEN` — fine-grained PAT used as `COPILOT_GITHUB_TOKEN` for Copilot CLI analysis.
17 +- `GITHUB_TOKEN` — built-in workflow token used for crawling, artifact downloads, commits, fallback GitHub Models calls, and Pages deployment.
18 +
19 +## Stage-by-stage validation
20 +
21 +### 1. Crawl
22 +
23 +**Job:** `crawl`
24 +
25 +**Inputs**
26 +- GitHub API access via `GITHUB_TOKEN`
27 +- Restored `crawl-cache` artifact from the latest successful workflow run when available
28 +
29 +**Outputs**
30 +- `data/raw/YYYY-WNN.json`
31 +- `data/snapshots/YYYY-WNN-stars.json`
32 +- `raw-data` artifact
33 +- `crawl-snapshots` artifact
34 +- `crawl-cache` artifact
35 +- Commit to `main` for `data/raw/` and `data/snapshots/`
36 +
37 +**Success criteria**
38 +- Raw payload passes `scripts.crawl.validate_payload()`
39 +- Snapshot file is written for the same ISO week
40 +- Cache artifact uploads even on partial failures
41 +- Job permissions include `actions: read` and `contents: write` at workflow level for cache restore and commits
42 +
43 +### 2. Analyze
44 +
45 +**Job:** `analyze`
46 +
47 +**Inputs**
48 +- `raw-data` artifact downloaded into `data/raw/`
49 +- `COPILOT_GH_TOKEN` for Copilot CLI primary path
50 +- `GITHUB_TOKEN` for GitHub Models fallback
51 +
52 +**Outputs**
53 +- `data/analyzed/YYYY-WNN-summary.md`
54 +- `analyzed-data` artifact
55 +- Commit to `main` for `data/analyzed/`
56 +- Job outputs: `week`, `summary_file`, `current_datetime`
57 +
58 +**Success criteria**
59 +- Current raw file week matches the run week
60 +- Copilot CLI output or fallback output is written to `data/analyzed/`
61 +- `scripts/analysis_gate.py` passes before publish continues
62 +- Job permissions include `actions: read`, `contents: write`, `copilot-requests: write`, and `models: read`
63 +
64 +### 3. Generate
65 +
66 +**Job:** `generate`
67 +
68 +**Inputs**
69 +- `analyzed-data` artifact downloaded into `data/analyzed/`
70 +- `needs.analyze.outputs.summary_file`
71 +
72 +**Outputs**
73 +- `content/weekly/YYYY/WNN.md`
74 +- `generated-content` artifact
75 +- Commit to `main` for `content/weekly/`
76 +- Job output: `page_path`
77 +
78 +**Success criteria**
79 +- `scripts/generate_content.py` converts the weekly summary into Hugo content
80 +- Generated frontmatter keeps publishable fields and drops analysis-only fields like `quality_score`
81 +- Job permissions include `actions: read` and `contents: write`
82 +
83 +### 4. Deploy
84 +
85 +**Job:** `deploy`
86 +
87 +**Inputs**
88 +- Repository checkout with submodules
89 +- `raw-data`, `analyzed-data`, and `generated-content` artifacts restored into the workspace
90 +- Hugo extended `0.161.1`
91 +
92 +**Outputs**
93 +- `public/` site build
94 +- GitHub Pages artifact uploaded with `actions/upload-pages-artifact`
95 +- Published Pages deployment via `actions/deploy-pages`
96 +
97 +**Success criteria**
98 +- Hugo build succeeds with the pinned version
99 +- Pages artifact is uploaded from `./public`
100 +- Deployment publishes from the same workflow run that generated the content
101 +- Job permissions include `actions: read`, `contents: read`, `pages: write`, and `id-token: write`
102 +
103 +## Artifact handoff audit
104 +
105 +- `crawl` → `analyze`: `raw-data`
106 +- `crawl` → later runs: `crawl-cache`
107 +- `analyze` → `generate`: `analyzed-data`
108 +- `generate` → `deploy`: `generated-content`
109 +- `crawl` and `analyze` also feed `deploy` so the final build uses the same run's data artifacts
110 +
111 +## Manual validation flow
112 +
113 +### Trigger from GitHub
114 +
115 +- Actions tab → **Crawl and publish weekly data** → **Run workflow**
116 +- CLI: `gh workflow run crawl-and-publish.yml`
117 +
118 +### Trigger locally
119 +
120 +- Crawl: `python3 scripts/crawl.py --as-of YYYY-MM-DD`
121 +- Analyze fallback: `python3 scripts/analyze_fallback.py --raw-json data/raw/YYYY-WNN.json --output data/analyzed/YYYY-WNN-summary.md --current-datetime YYYY-MM-DDTHH:MM:SSZ`
122 +- Gate: `python3 scripts/analysis_gate.py --analysis-file data/analyzed/YYYY-WNN-summary.md --raw-json data/raw/YYYY-WNN.json --current-datetime YYYY-MM-DDTHH:MM:SSZ`
123 +- Generate: `python3 scripts/generate_content.py data/analyzed/YYYY-WNN-summary.md`
124 +- Deploy build check: `hugo --minify`
125 +
126 +## Known limitations and workarounds
127 +
128 +- Copilot CLI in CI depends on `COPILOT_GH_TOKEN`; when unavailable, the workflow falls back to GitHub Models automatically.
129 +- Weekly momentum quality is only as good as the historical star snapshots; first runs and sparse history can make `stars_gained` incomplete.
130 +- Hugo must be `0.146.0+`; the workflow pins `0.161.1` because older runner binaries fail with the current theme.
131 +- The scheduled workflow now deploys Pages directly. `deploy-site.yml` skips bot-authored pushes so the scheduled run does not trigger a duplicate Pages deployment.
tests/test_pipeline.py new
+324
@@ -0,0 +1,324 @@
1 +import io
2 +import json
3 +import tempfile
4 +import unittest
5 +from argparse import Namespace
6 +from datetime import UTC, datetime
7 +from pathlib import Path
8 +from unittest import mock
9 +
10 +import scripts.analysis_gate as analysis_gate
11 +import scripts.analyze_fallback as analyze_fallback
12 +import scripts.crawl as crawl
13 +import scripts.generate_content as generate_content
14 +
15 +
16 +class _FakeHTTPResponse(io.BytesIO):
17 + def __enter__(self):
18 + return self
19 +
20 + def __exit__(self, exc_type, exc, tb):
21 + self.close()
22 + return False
23 +
24 +
25 +FIXED_RUN_DATETIME = "2026-05-18T08:00:00Z"
26 +FIXED_RUN_TIME = datetime(2026, 5, 18, 8, 0, 0, tzinfo=UTC)
27 +
28 +
29 +def make_api_repo(full_name: str, *, stars: int, created_at: str, topics: list[str]) -> dict:
30 + owner, name = full_name.split("/", 1)
31 + return {
32 + "name": name,
33 + "full_name": full_name,
34 + "description": f"{name} helps teams ship reliable automation.",
35 + "language": "Python",
36 + "stargazers_count": stars,
37 + "forks_count": max(1, stars // 10),
38 + "created_at": created_at,
39 + "topics": topics,
40 + "license": {"spdx_id": "MIT"},
41 + "html_url": f"https://github.com/{full_name}",
42 + "owner": {"login": owner},
43 + "fork": False,
44 + "is_template": False,
45 + }
46 +
47 +
48 +def make_raw_payload() -> dict:
49 + return {
50 + "week": "2026-W21",
51 + "crawled_at": FIXED_RUN_DATETIME,
52 + "new_repos": [
53 + {
54 + "name": "signal-kit",
55 + "owner": "octo",
56 + "full_name": "octo/signal-kit",
57 + "description": "Signal extraction for release teams.",
58 + "language": "Python",
59 + "stars": 120,
60 + "forks": 12,
61 + "created_at": "2026-05-12T09:00:00Z",
62 + "topics": ["ai", "automation", "developer-tooling"],
63 + "license": "MIT",
64 + "url": "https://github.com/octo/signal-kit",
65 + }
66 + ],
67 + "trending_repos": [
68 + {
69 + "name": "momentum-watch",
70 + "owner": "octo",
71 + "full_name": "octo/momentum-watch",
72 + "description": "Observability for weekly launches.",
73 + "language": "Go",
74 + "stars": 180,
75 + "forks": 18,
76 + "created_at": "2026-05-10T12:00:00Z",
77 + "topics": ["observability", "analytics", "platform"],
78 + "license": "Apache-2.0",
79 + "url": "https://github.com/octo/momentum-watch",
80 + "stars_gained": 35,
81 + }
82 + ],
83 + "signals": {
84 + "top_topics": [
85 + {"topic": "automation", "count": 2},
86 + {"topic": "observability", "count": 1},
87 + ]
88 + },
89 + "metadata": {
90 + "api_calls_used": 2,
91 + "cache_hits": 1,
92 + "stale_cache_hits": 0,
93 + "rate_limit_limit": 5000,
94 + "rate_limit_remaining": 4990,
95 + "rate_limit_reset": 1747567200,
96 + "rate_limit_resource": "search",
97 + "partial_failures": [],
98 + "snapshot_path": "data/snapshots/2026-W21-stars.json",
99 + },
100 + }
101 +
102 +
103 +def make_analysis_markdown() -> str:
104 + return f'''---
105 +title: "Week 21, 2026 Analysis"
106 +date: {FIXED_RUN_DATETIME}
107 +week: "2026-W21"
108 +year: 2026
109 +tags: [ai, automation, developer-tooling]
110 +categories: [weekly]
111 +repos_featured: 2
112 +stars_tracked: 300
113 +top_repo: "octo/signal-kit"
114 +quality_score: 86
115 +summary: "Reliable automation and observability projects set the tone for the week."
116 +---
117 +
118 +## Notable New Repositories
119 +
120 +[octo/signal-kit](https://github.com/octo/signal-kit) stood out because it solves release coordination without pretending to be a full platform rewrite. The project packages practical automation, readable defaults, and evidence of disciplined engineering. Teams watching shipping velocity can understand why it matters in one pass, which is a stronger signal than yet another thin wrapper around generic assistants. The repo reads like operational software built for repeat use instead of launch-day theater.
121 +
122 +## Trending This Week
123 +
124 +[octo/momentum-watch](https://github.com/octo/momentum-watch) captured attention because the work is grounded in observability and run health rather than novelty claims. The weekly delta is directionally useful here, and the trend matters because more teams are prioritizing measurement, incident feedback loops, and durable visibility into developer workflows instead of vanity dashboards.
125 +
126 +## Trend Analysis
127 +
128 +### Signal
129 +
130 +The durable signal is a return to automation that lowers toil and gives teams more confidence in repeatable delivery. [octo/signal-kit](https://github.com/octo/signal-kit) and [octo/momentum-watch](https://github.com/octo/momentum-watch) both point toward software that reduces coordination overhead, improves trust in pipelines, and respects how operators actually work. That pattern is more convincing than broad claims about agents replacing engineering judgment.
131 +
132 +### Noise
133 +
134 +The weak signal is the usual rush of products that market autonomy without proving fit, maintenance discipline, or measurable outcomes. This week was healthier than most, but the broader ecosystem still produces wrappers that borrow the language of automation while skipping the hard parts of observability, testing, and operational ownership.
135 +
136 +## What's Missing
137 +
138 +### Gaps
139 +
140 +The biggest gap is stronger investment in security review, test ergonomics, and smaller-team operations tooling that can be adopted without a platform migration. The ecosystem is getting better at coordination, but it still underserves practical defensive tooling and deployment confidence for teams that need reliability before they need spectacle.
141 +
142 +## Conclusion
143 +
144 +The week matters because practical automation won attention on merit. If this pattern holds, the next wave of winners will be tools that save teams time, expose real operating signals, and make release quality easier to trust.
145 +'''
146 +
147 +
148 +class PipelineIntegrationTests(unittest.TestCase):
149 + def test_crawl_script_produces_valid_json_output_schema(self) -> None:
150 + tests_root = Path(__file__).resolve().parent
151 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
152 + base = Path(tmpdir)
153 + output_path = base / "data" / "raw" / "2026-W21.json"
154 + snapshot_dir = base / "data" / "snapshots"
155 + snapshot_dir.mkdir(parents=True)
156 +
157 + new_repo = make_api_repo(
158 + "octo/signal-kit",
159 + stars=120,
160 + created_at="2026-05-12T09:00:00Z",
161 + topics=["ai", "automation", "developer-tooling"],
162 + )
163 + trending_repo = make_api_repo(
164 + "octo/momentum-watch",
165 + stars=180,
166 + created_at="2026-05-10T12:00:00Z",
167 + topics=["observability", "analytics", "platform"],
168 + )
169 +
170 + class FakeClient:
171 + def __init__(self, token: str) -> None:
172 + self.token = token
173 + self.api_calls_used = 2
174 + self.cache_hits = 1
175 + self.stale_cache_hits = 0
176 + self.rate_limit_limit = 5000
177 + self.rate_limit_remaining = 4990
178 + self.rate_limit_reset = 1747567200
179 + self.rate_limit_resource = "search"
180 + self.errors = []
181 +
182 + def search_repositories(self, query: str, *, max_results: int = 1000):
183 + if query.startswith("created:"):
184 + return [new_repo]
185 + if query.startswith("pushed:"):
186 + return [trending_repo]
187 + raise AssertionError(f"Unexpected query: {query}")
188 +
189 + def has_readme(self, full_name: str) -> bool:
190 + return True
191 +
192 + args = Namespace(
193 + since="2026-05-11",
194 + as_of="2026-05-18",
195 + max_results=10,
196 + output=str(output_path),
197 + )
198 +
199 + with mock.patch.object(crawl, "parse_args", return_value=args), mock.patch.dict(
200 + "os.environ", {"GITHUB_TOKEN": "token"}, clear=False
201 + ), mock.patch.object(crawl, "GitHubClient", FakeClient), mock.patch.object(
202 + crawl, "load_previous_star_snapshot", return_value={"octo/momentum-watch": 145}
203 + ), mock.patch.object(crawl, "utc_now", return_value=FIXED_RUN_TIME), mock.patch.object(
204 + crawl, "SNAPSHOT_ROOT", snapshot_dir
205 + ):
206 + exit_code = crawl.main()
207 +
208 + self.assertEqual(exit_code, 0)
209 + payload = json.loads(output_path.read_text(encoding="utf-8"))
210 + crawl.validate_payload(payload)
211 + self.assertEqual(payload["week"], "2026-W21")
212 + self.assertEqual(payload["trending_repos"][0]["stars_gained"], 35)
213 + self.assertTrue((snapshot_dir / "2026-W21-stars.json").exists())
214 +
215 + def test_generate_content_produces_valid_hugo_content(self) -> None:
216 + tests_root = Path(__file__).resolve().parent
217 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
218 + base = Path(tmpdir)
219 + summary_path = base / "data" / "analyzed" / "2026-W21-summary.md"
220 + summary_path.parent.mkdir(parents=True)
221 + summary_path.write_text(make_analysis_markdown(), encoding="utf-8")
222 +
223 + previous_cwd = Path.cwd()
224 + try:
225 + import os
226 +
227 + os.chdir(base)
228 + output_path = generate_content.generate_content(summary_path)
229 + finally:
230 + os.chdir(previous_cwd)
231 +
232 + self.assertEqual(output_path, base / "content" / "weekly" / "2026" / "W21.md")
233 + rendered = output_path.read_text(encoding="utf-8")
234 + self.assertIn('title: "Week 21, 2026"', rendered)
235 + self.assertIn('week: "2026-W21"', rendered)
236 + self.assertIn("draft: false", rendered)
237 + self.assertNotIn("quality_score", rendered)
238 + self.assertIn("## Notable New Repositories", rendered)
239 +
240 + def test_analyze_fallback_can_process_raw_data(self) -> None:
241 + tests_root = Path(__file__).resolve().parent
242 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
243 + base = Path(tmpdir)
244 + raw_path = base / "data" / "raw" / "2026-W21.json"
245 + output_path = base / "data" / "analyzed" / "2026-W21-summary.md"
246 + raw_path.parent.mkdir(parents=True)
247 + output_path.parent.mkdir(parents=True)
248 + raw_path.write_text(json.dumps(make_raw_payload()), encoding="utf-8")
249 +
250 + response = _FakeHTTPResponse(
251 + json.dumps({"choices": [{"message": {"content": make_analysis_markdown()}}]}).encode("utf-8")
252 + )
253 +
254 + with mock.patch.dict("os.environ", {"GITHUB_TOKEN": "token"}, clear=False), mock.patch.object(
255 + analyze_fallback.request, "urlopen", return_value=response
256 + ):
257 + exit_code = analyze_fallback.main(
258 + [
259 + "--raw-json",
260 + str(raw_path),
261 + "--output",
262 + str(output_path),
263 + "--current-datetime",
264 + FIXED_RUN_DATETIME,
265 + "--analyzed-dir",
266 + str(output_path.parent),
267 + ]
268 + )
269 +
270 + self.assertEqual(exit_code, 0)
271 + written = output_path.read_text(encoding="utf-8")
272 + self.assertIn("Week 21, 2026 Analysis", written)
273 + self.assertIn("## Trend Analysis", written)
274 +
275 + def test_analysis_gate_validates_analysis_output_correctly(self) -> None:
276 + tests_root = Path(__file__).resolve().parent
277 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
278 + base = Path(tmpdir)
279 + raw_path = base / "data" / "raw" / "2026-W21.json"
280 + raw_path.parent.mkdir(parents=True)
281 + raw_path.write_text(json.dumps(make_raw_payload()), encoding="utf-8")
282 +
283 + valid_path = base / "data" / "analyzed" / "2026-W21-summary.md"
284 + valid_path.parent.mkdir(parents=True)
285 + valid_path.write_text(make_analysis_markdown(), encoding="utf-8")
286 +
287 + self.assertEqual(
288 + analysis_gate.main(
289 + [
290 + "--analysis-file",
291 + str(valid_path),
292 + "--raw-json",
293 + str(raw_path),
294 + "--current-datetime",
295 + FIXED_RUN_DATETIME,
296 + "--source",
297 + "integration-test",
298 + ]
299 + ),
300 + 0,
301 + )
302 +
303 + invalid_path = base / "data" / "analyzed" / "invalid-summary.md"
304 + invalid_path.write_text(make_analysis_markdown().replace("quality_score: 86", "quality_score: 40"), encoding="utf-8")
305 +
306 + with self.assertRaises(SystemExit) as exc:
307 + analysis_gate.main(
308 + [
309 + "--analysis-file",
310 + str(invalid_path),
311 + "--raw-json",
312 + str(raw_path),
313 + "--current-datetime",
314 + FIXED_RUN_DATETIME,
315 + "--source",
316 + "integration-test",
317 + ]
318 + )
319 +
320 + self.assertEqual(exc.exception.code, 1)
321 +
322 +
323 +if __name__ == "__main__":
324 + unittest.main()