Coverage for plugin/scripts/_rhiza_changelog.py: 100%
13 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
1#!/usr/bin/env python3
2r"""Read the newest version a `CHANGELOG.md` names — the one parser, for both readers.
4`/rhiza:release` asks this question twice, from two directions, and both answers have to
5agree or the release strands:
7* `check_version_bump.py` reads the **local** file to decide which phase the repo is in.
8 On a tag-derived repo it is the only evidence that a bump has landed, because there the
9 declared version *is* the newest tag and so can never exceed it.
10* `wait_for_merge.py` reads the file **on the remote default branch**, through
11 `git show`, to decide whether the release PR's bump has arrived.
13Two copies of one regex is how those two come to disagree — one accepting `## [v1.7.0]`
14and the other not, on a repo whose `cliff.toml` writes the prefix, would leave the wait
15polling forever for a release the phase check can already see. So the pattern lives here
16and neither caller owns it.
18What counts as a release heading is deliberately loose about everything except the
19version: the level (`#` to `###`), the brackets, and a `v` prefix are all `cliff.toml`
20decisions or hand-editing habits, and all of them appear in real changelogs. The digits
21are the part that is not optional, which is what makes an `Unreleased` section invisible
22here rather than something to special-case.
23"""
25from __future__ import annotations
27import re
28from pathlib import Path
30_CHANGELOG_HEADING = re.compile(r"^#{1,3}\s*\[?v?(\d+\.\d+\.\d+[0-9A-Za-z.+-]*)\]?", re.M)
33def newest_changelog_version(text: str) -> str | None:
34 r"""Return the newest version a changelog's headings name, or ``None``.
36 This is the committed evidence a tag-derived repo has and a manifest-declared one
37 does not. `/rhiza:release` writes the section with `git-cliff --prepend`, so the
38 newest release is the **first** version-shaped heading in the file.
40 >>> newest_changelog_version("# Changelog\n\n## [1.7.0] - 2026-09-07\n\n## [1.6.0]\n")
41 '1.7.0'
43 An `Unreleased` heading is not version-shaped, so it is skipped rather than
44 swallowing the section under it:
46 >>> newest_changelog_version("## [Unreleased]\n\n## [1.6.0] - 2026-09-04\n")
47 '1.6.0'
49 A `v` prefix, a bare heading with no brackets, and a deeper level all parse:
51 >>> [
52 ... newest_changelog_version("## [v2.0.0]\n"),
53 ... newest_changelog_version("## 0.9.1 - 2026-01-01\n"),
54 ... newest_changelog_version("### [1.0.0-rc.1]\n"),
55 ... ]
56 ['2.0.0', '0.9.1', '1.0.0-rc.1']
58 A file with no version heading at all yields nothing, rather than a guess:
60 >>> print(newest_changelog_version("# Changelog\n\nNothing released yet.\n"))
61 None
62 """
63 match = _CHANGELOG_HEADING.search(text)
64 return match[1] if match else None
67def read_changelog_version(path: Path) -> str | None:
68 """Return the newest version named by the changelog at *path*, or ``None``.
70 A missing or unreadable file is not an error: a repo need not keep a changelog, and
71 the phase decision in `check_version_bump.py` is what turns "no evidence" into a
72 verdict. Reading it here keeps the markdown parsing in tested Python rather than in a
73 caller's regex.
74 """
75 try:
76 text = path.read_text(encoding="utf-8", errors="replace")
77 except OSError:
78 return None
79 return newest_changelog_version(text)