返回 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_run_block_indent_checker_rejects_column_zero(self) -> None:
176 malformed = (
177 "jobs:\n"
178 " guard:\n"
179 " steps:\n"
180 " - name: Enforce\n"
181 " run: |\n"
182 " set -euo pipefail\n"
183 "import json\n"
184 )
185 with self.assertRaises(AssertionError):
186 _assert_run_blocks_indented(malformed, label="synthetic")
187
188 def test_read_manifest_version_helper(self) -> None:
189 script = ROOT / ".github" / "scripts" / "read_manifest_version.py"
190 self.assertTrue(script.is_file())
191 spec = importlib.util.spec_from_file_location("read_manifest_version", script)
192 assert spec and spec.loader
193 mod = importlib.util.module_from_spec(spec)
194 spec.loader.exec_module(mod)
195 self.assertEqual(
196 mod.version_from("pyproject.toml", 'version = "3.18.1"\n'),
197 "3.18.1",
198 )
199 self.assertEqual(
200 mod.version_from("skills/last30days/SKILL.md", 'version: "3.18.1"\n'),
201 "3.18.1",
202 )
203 self.assertEqual(
204 mod.version_from(
205 "uv.lock",
206 '[[package]]\nname = "last30days-skill"\nversion = "3.18.1"\n',
207 ),
208 "3.18.1",
209 )
210 self.assertEqual(
211 mod.version_from(
212 ".claude-plugin/plugin.json",
213 json.dumps({"version": "3.18.1"}),
214 ),
215 "3.18.1",
216 )
217 self.assertEqual(
218 mod.version_from(
219 ".claude-plugin/marketplace.json",
220 json.dumps({"plugins": [{"version": "3.18.1"}]}),
221 ),
222 "3.18.1",
223 )
224 with self.assertRaises(SystemExit):
225 mod.version_from(".claude-plugin/plugin.json", "{not-json")
226
227 def test_pr_template_has_agent_and_relationship_sections(self) -> None:
228 text = (ROOT / ".github" / "PULL_REQUEST_TEMPLATE.md").read_text(
229 encoding="utf-8"
230 )
231 self.assertIn("## Agent disclosure", text)
232 self.assertIn("### Relationship to this change", text)
233 self.assertIn("changelog.d/", text)
234 self.assertIn("What does this PR do?", text)
235 self.assertTrue((ROOT / "CONTRIBUTING.md").is_file())
236
237 def test_next_version_bumps(self) -> None:
238 mod = _load_prepare_release()
239 self.assertEqual(mod.next_version("3.18.1", "patch"), "3.18.2")
240 self.assertEqual(mod.next_version("3.18.1", "minor"), "3.19.0")
241 self.assertEqual(mod.next_version("3.18.1", "major"), "4.0.0")
242
243 def test_main_refuses_equal_version_outside_dry_run(self) -> None:
244 mod = _load_prepare_release()
245 with tempfile.TemporaryDirectory() as tmp:
246 tmp_path = Path(tmp)
247 (tmp_path / "pyproject.toml").write_text(
248 '[project]\nname = "last30days-skill"\nversion = "3.18.1"\n',
249 encoding="utf-8",
250 )
251 mod.ROOT = tmp_path
252 mod.PYPROJECT = tmp_path / "pyproject.toml"
253 with self.assertRaises(SystemExit) as ctx:
254 mod.main(["--version", "3.18.1"])
255 self.assertIn("Refusing to re-release", str(ctx.exception))
256
257 def test_bump_all_updates_lockstep_surfaces(self) -> None:
258 mod = _load_prepare_release()
259 with tempfile.TemporaryDirectory() as tmp:
260 tmp_path = Path(tmp)
261 # Minimal fixtures mirroring the lockstep layout.
262 (tmp_path / "skills" / "last30days").mkdir(parents=True)
263 (tmp_path / ".claude-plugin").mkdir()
264 (tmp_path / ".codex-plugin").mkdir()
265 (tmp_path / ".grok-plugin").mkdir()
266
267 (tmp_path / "pyproject.toml").write_text(
268 '[project]\nname = "last30days-skill"\nversion = "3.18.1"\n',
269 encoding="utf-8",
270 )
271 (tmp_path / "skills" / "last30days" / "SKILL.md").write_text(
272 '---\nversion: "3.18.1"\n---\n\n# last30days v3.18.1: Title\n',
273 encoding="utf-8",
274 )
275 for rel in (
276 ".claude-plugin/plugin.json",
277 ".codex-plugin/plugin.json",
278 ".grok-plugin/plugin.json",
279 "gemini-extension.json",
280 ):
281 (tmp_path / rel).write_text(
282 json.dumps({"name": "last30days", "version": "3.18.1"}, indent=2)
283 + "\n",
284 encoding="utf-8",
285 )
286 for rel in (
287 ".claude-plugin/marketplace.json",
288 ".grok-plugin/marketplace.json",
289 ):
290 (tmp_path / rel).write_text(
291 json.dumps(
292 {
293 "name": "last30days-skill",
294 "plugins": [{"name": "last30days", "version": "3.18.1"}],
295 },
296 indent=2,
297 )
298 + "\n",
299 encoding="utf-8",
300 )
301 (tmp_path / "uv.lock").write_text(
302 'version = 1\n\n[[package]]\nname = "last30days-skill"\n'
303 'version = "3.18.1"\nsource = { virtual = "." }\n',
304 encoding="utf-8",
305 )
306
307 # Point module paths at the temp tree.
308 mod.ROOT = tmp_path
309 mod.SKILL_MD = tmp_path / "skills" / "last30days" / "SKILL.md"
310 mod.PYPROJECT = tmp_path / "pyproject.toml"
311 mod.UV_LOCK = tmp_path / "uv.lock"
312 mod.JSON_VERSION_FILES = (
313 tmp_path / ".claude-plugin" / "plugin.json",
314 tmp_path / ".codex-plugin" / "plugin.json",
315 tmp_path / ".grok-plugin" / "plugin.json",
316 tmp_path / "gemini-extension.json",
317 )
318 mod.MARKETPLACE_FILES = (
319 tmp_path / ".claude-plugin" / "marketplace.json",
320 tmp_path / ".grok-plugin" / "marketplace.json",
321 )
322
323 touched = mod.bump_all("9.9.9")
324 self.assertEqual(len(touched), 9)
325
326 pyproject = (tmp_path / "pyproject.toml").read_text(encoding="utf-8")
327 self.assertIn('version = "9.9.9"', pyproject)
328
329 skill = (tmp_path / "skills" / "last30days" / "SKILL.md").read_text(
330 encoding="utf-8"
331 )
332 self.assertIn('version: "9.9.9"', skill)
333 self.assertIn("# last30days v9.9.9:", skill)
334
335 for rel in (
336 ".claude-plugin/plugin.json",
337 ".codex-plugin/plugin.json",
338 ".grok-plugin/plugin.json",
339 "gemini-extension.json",
340 ):
341 data = json.loads((tmp_path / rel).read_text(encoding="utf-8"))
342 self.assertEqual(data["version"], "9.9.9", msg=rel)
343
344 for rel in (
345 ".claude-plugin/marketplace.json",
346 ".grok-plugin/marketplace.json",
347 ):
348 data = json.loads((tmp_path / rel).read_text(encoding="utf-8"))
349 self.assertEqual(data["plugins"][0]["version"], "9.9.9", msg=rel)
350
351 uv_lock = (tmp_path / "uv.lock").read_text(encoding="utf-8")
352 self.assertRegex(
353 uv_lock,
354 re.compile(
355 r'(?ms)^\[\[package\]\]\nname = "last30days-skill"\nversion = "9\.9\.9"',
356 ),
357 )
358
359
360 if __name__ == "__main__":
361 unittest.main()
362
362 lines PYTHON