返回 last30days-skill
test_changelog_workflow.py
根目录 / tests / test_changelog_workflow.py
1 """Contract tests for towncrier + lockstep release preparation."""
2
3 from __future__ import annotations
4
5 import importlib.util
6 import json
7 import os
8 import re
9 import subprocess
10 import tempfile
11 import unittest
12 from pathlib import Path
13
14 import yaml
15
16 ROOT = Path(__file__).resolve().parents[1]
17
18
19 def _assert_run_blocks_indented(text: str, *, label: str) -> None:
20 """Fail if a ``run: |`` block contains under-indented (esp. column-0) lines.
21
22 A prior bug treated indent <= run_indent as "end of block" and skipped the
23 assertion, so column-0 Python inside the scalar never failed the test.
24 """
25 in_run = False
26 run_indent = 0
27 for lineno, line in enumerate(text.splitlines(), 1):
28 match = re.match(r"^(\s*)run:\s*\|\s*$", line)
29 if match:
30 in_run = True
31 run_indent = len(match.group(1))
32 continue
33 if not in_run or not line.strip():
34 continue
35 indent = len(line) - len(line.lstrip(" "))
36 if indent > run_indent:
37 continue
38 # Valid block end: a YAML key or list item at/above the run: indent.
39 # Column-0 (or any non-key dedent) is the Actions parse failure.
40 looks_like_yaml = bool(
41 re.match(r"^\s*(- )?[A-Za-z_][\w-]*\s*:", line)
42 or re.match(r"^\s*-\s+\S", line)
43 )
44 if indent == 0 or not looks_like_yaml:
45 raise AssertionError(
46 f"{label}:{lineno} under-indented inside run: | "
47 f"(indent={indent}, run_indent={run_indent}): {line!r}"
48 )
49 in_run = False
50
51
52 def _tag_release_workflow_text() -> str:
53 return (ROOT / ".github" / "workflows" / "tag-release.yml").read_text(
54 encoding="utf-8"
55 )
56
57
58 def _tag_release_version_sed_expr(workflow_text: str) -> str:
59 """Extract the sed expression from tag-release.yml's VERSION pipeline."""
60 match = re.search(
61 r'printf \'%s\\n\' "\$\{HEAD_MSG\}" \| sed -n \'([^\']+)\' \| head -n1',
62 workflow_text,
63 )
64 if not match:
65 raise AssertionError(
66 "Could not find VERSION sed pipeline in tag-release.yml"
67 )
68 return match.group(1)
69
70
71 def _parse_release_version_from_message(message: str, sed_expr: str) -> str:
72 """Run the same sed pipeline tag-release.yml uses to extract VERSION."""
73 result = subprocess.run(
74 [
75 "bash",
76 "-c",
77 f"printf '%s\\n' \"${{HEAD_MSG}}\" | sed -n '{sed_expr}' | head -n1",
78 ],
79 capture_output=True,
80 text=True,
81 check=True,
82 env={**os.environ, "HEAD_MSG": message},
83 )
84 return result.stdout.strip()
85
86
87 def _load_prepare_release():
88 path = ROOT / ".github" / "scripts" / "prepare_release.py"
89 spec = importlib.util.spec_from_file_location("prepare_release", path)
90 assert spec and spec.loader
91 module = importlib.util.module_from_spec(spec)
92 spec.loader.exec_module(module)
93 return module
94
95
96 class TestChangelogWorkflow(unittest.TestCase):
97 def test_towncrier_config_present(self) -> None:
98 text = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
99 self.assertIn("[tool.towncrier]", text)
100 self.assertIn('directory = "changelog.d"', text)
101 self.assertIn('filename = "CHANGELOG.md"', text)
102 for fragment_type in (
103 "security",
104 "removed",
105 "deprecated",
106 "added",
107 "changed",
108 "fixed",
109 ):
110 self.assertIn(f'directory = "{fragment_type}"', text)
111
112 def test_changelog_has_towncrier_start_marker(self) -> None:
113 text = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
114 self.assertIn("<!-- towncrier release notes start -->", text)
115 self.assertNotIn("## [Unreleased]", text)
116
117 def test_changelog_d_readme_exists(self) -> None:
118 self.assertTrue((ROOT / "changelog.d" / "README.md").is_file())
119
120 def test_prepare_release_script_exists(self) -> None:
121 self.assertTrue((ROOT / ".github" / "scripts" / "prepare_release.py").is_file())
122
123 def test_release_workflows_exist(self) -> None:
124 workflows = ROOT / ".github" / "workflows"
125 for name in (
126 "prepare-release.yml",
127 "tag-release.yml",
128 "changelog-guard.yml",
129 ):
130 self.assertTrue((workflows / name).is_file(), msg=name)
131
132 def test_tag_release_workflow_yaml_parses(self) -> None:
133 """Bare ``chore(release):`` in an unquoted ``if:`` breaks Actions YAML.
134
135 GitHub then reports the run as failed with zero jobs on every push to
136 main. The expression must be double-quoted; prefer ``contains`` so
137 merge-commit messages still match.
138 """
139 text = _tag_release_workflow_text()
140 yaml.safe_load(text)
141 self.assertRegex(
142 text,
143 r'(?m)^\s+if:\s+"contains\(github\.event\.head_commit\.message, '
144 r"'chore\(release\): bump version to '\)\"\s*$",
145 )
146 # VERSION parse must scan the full message (merge commits put the
147 # chore line in the body, not on line 1).
148 self.assertNotIn("| head -n1 | sed -n", text)
149
150 def test_tag_release_workflow_version_extraction(self) -> None:
151 """VERSION sed must match direct and merge-commit message shapes."""
152 text = _tag_release_workflow_text()
153 sed_expr = _tag_release_version_sed_expr(text)
154 expected = "3.18.3"
155 direct = f"chore(release): bump version to {expected}"
156 merge = (
157 "Merge pull request #879 from mvanhorn/release/v3.18.3\n\n"
158 f"chore(release): bump version to {expected}"
159 )
160 for message in (direct, merge):
161 with self.subTest(message=message.splitlines()[0]):
162 self.assertEqual(
163 _parse_release_version_from_message(message, sed_expr),
164 expected,
165 )
166
167 def test_changelog_guard_run_blocks_stay_indented(self) -> None:
168 """Column-0 lines inside ``run: |`` break Actions YAML parsing."""
169 path = ROOT / ".github" / "workflows" / "changelog-guard.yml"
170 _assert_run_blocks_indented(
171 path.read_text(encoding="utf-8"),
172 label=str(path),
173 )
174
175 def test_changelog_guard_skips_dependabot_without_fragment(self) -> None:
176 """Dependabot PRs must not need skip-changelog or a fragment.
177
178 mcp/* is an engine path, so gomod bumps fail the fragment gate unless
179 the author is exempted. Label-only exemption is not enough: Dependabot
180 cannot reliably apply skip-changelog (custom labels replace defaults
181 and missing repo labels are dropped). SKIP_CHANGELOG=1 from the
182 Dependabot author check must precede the fragment gate and must not
183 be reset afterwards.
184 """
185 text = (ROOT / ".github" / "workflows" / "changelog-guard.yml").read_text(
186 encoding="utf-8"
187 )
188 self.assertIn("PR_AUTHOR: ${{ github.event.pull_request.user.login }}", text)
189 self.assertIn("dependabot[bot]", text)
190 dependabot_assign = re.search(
191 r'if \[ "\$\{PR_AUTHOR\}" = "dependabot\[bot\]" \]; then\n'
192 r"\s+SKIP_CHANGELOG=1",
193 text,
194 )
195 self.assertIsNotNone(
196 dependabot_assign,
197 "Dependabot must set SKIP_CHANGELOG=1 from PR_AUTHOR",
198 )
199 fragment_gate = (
200 'if [ "${touches_engine}" -eq 1 ] && [ "${has_fragment}" -eq 0 ]'
201 ' && [ "${SKIP_CHANGELOG}" -eq 0 ]; then'
202 )
203 gate_at = text.find(fragment_gate)
204 self.assertNotEqual(
205 gate_at,
206 -1,
207 "Fragment gate must still consult SKIP_CHANGELOG",
208 )
209 self.assertLess(
210 dependabot_assign.start(),
211 gate_at,
212 "Dependabot SKIP_CHANGELOG=1 must precede the fragment gate",
213 )
214 self.assertNotIn(
215 "SKIP_CHANGELOG=0",
216 text[dependabot_assign.end() :],
217 "SKIP_CHANGELOG must not be reset after the Dependabot assignment",
218 )
219
220 def test_run_block_indent_checker_rejects_column_zero(self) -> None:
221 malformed = (
222 "jobs:\n"
223 " guard:\n"
224 " steps:\n"
225 " - name: Enforce\n"
226 " run: |\n"
227 " set -euo pipefail\n"
228 "import json\n"
229 )
230 with self.assertRaises(AssertionError):
231 _assert_run_blocks_indented(malformed, label="synthetic")
232
233 def test_read_manifest_version_helper(self) -> None:
234 script = ROOT / ".github" / "scripts" / "read_manifest_version.py"
235 self.assertTrue(script.is_file())
236 spec = importlib.util.spec_from_file_location("read_manifest_version", script)
237 assert spec and spec.loader
238 mod = importlib.util.module_from_spec(spec)
239 spec.loader.exec_module(mod)
240 self.assertEqual(
241 mod.version_from("pyproject.toml", 'version = "3.18.1"\n'),
242 "3.18.1",
243 )
244 self.assertEqual(
245 mod.version_from("skills/last30days/SKILL.md", 'version: "3.18.1"\n'),
246 "3.18.1",
247 )
248 self.assertEqual(
249 mod.version_from(
250 "uv.lock",
251 '[[package]]\nname = "last30days-skill"\nversion = "3.18.1"\n',
252 ),
253 "3.18.1",
254 )
255 self.assertEqual(
256 mod.version_from(
257 ".claude-plugin/plugin.json",
258 json.dumps({"version": "3.18.1"}),
259 ),
260 "3.18.1",
261 )
262 self.assertEqual(
263 mod.version_from(
264 ".claude-plugin/marketplace.json",
265 json.dumps({"plugins": [{"version": "3.18.1"}]}),
266 ),
267 "3.18.1",
268 )
269 self.assertEqual(
270 mod.version_from(
271 "mcp/manifest.json",
272 json.dumps({"manifest_version": "0.3", "version": "3.18.1"}),
273 ),
274 "3.18.1",
275 )
276 with self.assertRaises(SystemExit):
277 mod.version_from(".claude-plugin/plugin.json", "{not-json")
278
279 def test_pr_template_has_agent_and_relationship_sections(self) -> None:
280 text = (ROOT / ".github" / "PULL_REQUEST_TEMPLATE.md").read_text(
281 encoding="utf-8"
282 )
283 self.assertIn("## Agent disclosure", text)
284 self.assertIn("### Relationship to this change", text)
285 self.assertIn("changelog.d/", text)
286 self.assertIn("What does this PR do?", text)
287 self.assertTrue((ROOT / "CONTRIBUTING.md").is_file())
288
289 def test_next_version_bumps(self) -> None:
290 mod = _load_prepare_release()
291 self.assertEqual(mod.next_version("3.18.1", "patch"), "3.18.2")
292 self.assertEqual(mod.next_version("3.18.1", "minor"), "3.19.0")
293 self.assertEqual(mod.next_version("3.18.1", "major"), "4.0.0")
294
295 def test_main_refuses_equal_version_outside_dry_run(self) -> None:
296 mod = _load_prepare_release()
297 with tempfile.TemporaryDirectory() as tmp:
298 tmp_path = Path(tmp)
299 (tmp_path / "pyproject.toml").write_text(
300 '[project]\nname = "last30days-skill"\nversion = "3.18.1"\n',
301 encoding="utf-8",
302 )
303 mod.ROOT = tmp_path
304 mod.PYPROJECT = tmp_path / "pyproject.toml"
305 with self.assertRaises(SystemExit) as ctx:
306 mod.main(["--version", "3.18.1"])
307 self.assertIn("Refusing to re-release", str(ctx.exception))
308
309 def test_prepare_release_bumps_mcp_manifest(self) -> None:
310 mod = _load_prepare_release()
311 self.assertIn(ROOT / "mcp" / "manifest.json", mod.JSON_VERSION_FILES)
312
313 def test_changelog_guard_version_paths_match_release_bump_set(self) -> None:
314 """Every path prepare_release.py bumps must be guarded, and vice versa."""
315 mod = _load_prepare_release()
316 bump_set = {
317 str(path.relative_to(ROOT))
318 for path in (
319 mod.PYPROJECT,
320 mod.UV_LOCK,
321 mod.SKILL_MD,
322 *mod.JSON_VERSION_FILES,
323 *mod.MARKETPLACE_FILES,
324 )
325 }
326 text = (ROOT / ".github" / "workflows" / "changelog-guard.yml").read_text(
327 encoding="utf-8"
328 )
329 match = re.search(r"VERSION_PATHS=\(\n(.*?)\n\s*\)", text, re.DOTALL)
330 if not match:
331 raise AssertionError("VERSION_PATHS=( ... ) not found in changelog-guard.yml")
332 guarded = {line.strip() for line in match.group(1).splitlines() if line.strip()}
333 self.assertIn("mcp/manifest.json", guarded)
334 self.assertEqual(bump_set, guarded)
335
336 def test_prepare_release_workflow_stages_every_bumped_file(self) -> None:
337 """A file prepare_release.py rewrites but the workflow never stages is
338 silently dropped from the release commit, so the bump is lost and any
339 test waiting on the new value keeps skipping."""
340 mod = _load_prepare_release()
341 bump_set = {
342 str(path.relative_to(ROOT))
343 for path in (
344 mod.PYPROJECT,
345 mod.UV_LOCK,
346 mod.SKILL_MD,
347 *mod.JSON_VERSION_FILES,
348 *mod.MARKETPLACE_FILES,
349 )
350 }
351 text = (ROOT / ".github" / "workflows" / "prepare-release.yml").read_text(
352 encoding="utf-8"
353 )
354 match = re.search(r"\n\s*git add \\\n(.*?)\n\s*git status", text, re.DOTALL)
355 if not match:
356 raise AssertionError("git add ... block not found in prepare-release.yml")
357 staged = {
358 line.strip().rstrip("\\").strip()
359 for line in match.group(1).splitlines()
360 if line.strip()
361 }
362 self.assertIn("mcp/manifest.json", staged)
363 self.assertEqual(set(), bump_set - staged)
364
365 def test_bump_all_updates_lockstep_surfaces(self) -> None:
366 mod = _load_prepare_release()
367 with tempfile.TemporaryDirectory() as tmp:
368 tmp_path = Path(tmp)
369 # Minimal fixtures mirroring the lockstep layout.
370 (tmp_path / "skills" / "last30days").mkdir(parents=True)
371 (tmp_path / ".claude-plugin").mkdir()
372 (tmp_path / ".codex-plugin").mkdir()
373 (tmp_path / ".grok-plugin").mkdir()
374 (tmp_path / "mcp").mkdir()
375
376 (tmp_path / "pyproject.toml").write_text(
377 '[project]\nname = "last30days-skill"\nversion = "3.18.1"\n',
378 encoding="utf-8",
379 )
380 (tmp_path / "skills" / "last30days" / "SKILL.md").write_text(
381 '---\nversion: "3.18.1"\n---\n\n# last30days v3.18.1: Title\n',
382 encoding="utf-8",
383 )
384 for rel in (
385 ".claude-plugin/plugin.json",
386 ".codex-plugin/plugin.json",
387 ".grok-plugin/plugin.json",
388 "gemini-extension.json",
389 "mcp/manifest.json",
390 ):
391 (tmp_path / rel).write_text(
392 json.dumps({"name": "last30days", "version": "3.18.1"}, indent=2)
393 + "\n",
394 encoding="utf-8",
395 )
396 for rel in (
397 ".claude-plugin/marketplace.json",
398 ".grok-plugin/marketplace.json",
399 ):
400 (tmp_path / rel).write_text(
401 json.dumps(
402 {
403 "name": "last30days-skill",
404 "plugins": [{"name": "last30days", "version": "3.18.1"}],
405 },
406 indent=2,
407 )
408 + "\n",
409 encoding="utf-8",
410 )
411 (tmp_path / "uv.lock").write_text(
412 'version = 1\n\n[[package]]\nname = "last30days-skill"\n'
413 'version = "3.18.1"\nsource = { virtual = "." }\n',
414 encoding="utf-8",
415 )
416
417 # Point module paths at the temp tree.
418 mod.ROOT = tmp_path
419 mod.SKILL_MD = tmp_path / "skills" / "last30days" / "SKILL.md"
420 mod.PYPROJECT = tmp_path / "pyproject.toml"
421 mod.UV_LOCK = tmp_path / "uv.lock"
422 mod.JSON_VERSION_FILES = (
423 tmp_path / ".claude-plugin" / "plugin.json",
424 tmp_path / ".codex-plugin" / "plugin.json",
425 tmp_path / ".grok-plugin" / "plugin.json",
426 tmp_path / "gemini-extension.json",
427 tmp_path / "mcp" / "manifest.json",
428 )
429 mod.MARKETPLACE_FILES = (
430 tmp_path / ".claude-plugin" / "marketplace.json",
431 tmp_path / ".grok-plugin" / "marketplace.json",
432 )
433
434 touched = mod.bump_all("9.9.9")
435 self.assertEqual(len(touched), 10)
436
437 pyproject = (tmp_path / "pyproject.toml").read_text(encoding="utf-8")
438 self.assertIn('version = "9.9.9"', pyproject)
439
440 skill = (tmp_path / "skills" / "last30days" / "SKILL.md").read_text(
441 encoding="utf-8"
442 )
443 self.assertIn('version: "9.9.9"', skill)
444 self.assertIn("# last30days v9.9.9:", skill)
445
446 for rel in (
447 ".claude-plugin/plugin.json",
448 ".codex-plugin/plugin.json",
449 ".grok-plugin/plugin.json",
450 "gemini-extension.json",
451 "mcp/manifest.json",
452 ):
453 data = json.loads((tmp_path / rel).read_text(encoding="utf-8"))
454 self.assertEqual(data["version"], "9.9.9", msg=rel)
455
456 for rel in (
457 ".claude-plugin/marketplace.json",
458 ".grok-plugin/marketplace.json",
459 ):
460 data = json.loads((tmp_path / rel).read_text(encoding="utf-8"))
461 self.assertEqual(data["plugins"][0]["version"], "9.9.9", msg=rel)
462
463 uv_lock = (tmp_path / "uv.lock").read_text(encoding="utf-8")
464 self.assertRegex(
465 uv_lock,
466 re.compile(
467 r'(?ms)^\[\[package\]\]\nname = "last30days-skill"\nversion = "9\.9\.9"',
468 ),
469 )
470
471
472 if __name__ == "__main__":
473 unittest.main()
474
474 lines PYTHON