Skip to content

Commit 1a88941

Browse files
committed
fix: rewrite changelog updater for Keep a Changelog format
Avoid duplicate [Unreleased] headings and keep the preamble intact when preparing releases. Add unit tests and harden release workflow output.
1 parent 19fb878 commit 1a88941

4 files changed

Lines changed: 112 additions & 53 deletions

File tree

.github/workflows/release.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,11 @@ jobs:
3737
else
3838
body="Release ${version}."
3939
fi
40+
delimiter="EOF_${RANDOM}_${RANDOM}"
4041
{
41-
echo "body<<EOF"
42+
echo "body<<${delimiter}"
4243
echo "$body"
43-
echo "EOF"
44+
echo "${delimiter}"
4445
} >> "$GITHUB_OUTPUT"
4546
4647
- name: Create GitHub Release

scripts/release.sh

Lines changed: 1 addition & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -95,57 +95,7 @@ update_changelog() {
9595
local ver="$1"
9696
local date
9797
date="$(date +%Y-%m-%d)"
98-
python3 - "$ver" "$date" <<'PY'
99-
import re, sys
100-
from pathlib import Path
101-
102-
ver, date = sys.argv[1], sys.argv[2]
103-
path = Path("CHANGELOG.md")
104-
105-
if not path.exists():
106-
path.write_text(
107-
"# Changelog\n\n"
108-
"All notable changes to this project will be documented in this file.\n\n"
109-
"The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\n"
110-
"and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n"
111-
f"## [Unreleased]\n\n"
112-
f"## [{ver}] - {date}\n\n"
113-
"### Changed\n\n"
114-
f"- Release {ver}\n"
115-
)
116-
raise SystemExit(0)
117-
118-
text = path.read_text()
119-
if re.search(rf"^## \[{re.escape(ver)}\]", text, re.M):
120-
raise SystemExit(0)
121-
122-
unreleased = re.search(r"^## \[Unreleased\]\s*\n(.*?)(?=^## \[|\Z)", text, re.M | re.S)
123-
if unreleased and unreleased.group(1).strip():
124-
body = unreleased.group(1).rstrip() + "\n"
125-
text = re.sub(r"^## \[Unreleased\]\s*\n.*?(?=^## \[|\Z)", "", text, count=1, flags=re.M | re.S)
126-
insert = f"## [Unreleased]\n\n## [{ver}] - {date}\n{body}\n"
127-
if text.startswith("# Changelog"):
128-
parts = text.split("\n\n", 2)
129-
if len(parts) >= 2:
130-
text = parts[0] + "\n\n" + parts[1] + "\n\n" + insert + (parts[2] if len(parts) > 2 else "")
131-
else:
132-
text = insert + text
133-
else:
134-
text = insert + text
135-
else:
136-
insert = (
137-
f"## [Unreleased]\n\n"
138-
f"## [{ver}] - {date}\n\n"
139-
"### Changed\n\n"
140-
f"- Release {ver}\n\n"
141-
)
142-
if "## [Unreleased]" in text:
143-
text = text.replace("## [Unreleased]", insert.strip() + "\n\n## [Unreleased]", 1)
144-
else:
145-
text = re.sub(r"(^# Changelog.*?)(\n\n|\Z)", r"\1\n\n" + insert, text, count=1, flags=re.S)
146-
147-
path.write_text(text)
148-
PY
98+
python3 scripts/update_changelog.py "$ver" "$date"
14999
}
150100

151101
cmd_prepare() {

scripts/update_changelog.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
#!/usr/bin/env python3
2+
"""Update CHANGELOG.md for a new release version."""
3+
4+
from __future__ import annotations
5+
6+
import re
7+
import sys
8+
from pathlib import Path
9+
10+
11+
def update_changelog_text(text: str, ver: str, date: str) -> str:
12+
if re.search(rf"^## \[{re.escape(ver)}\]", text, re.M):
13+
return text
14+
15+
unreleased = re.search(r"^## \[Unreleased\]\s*\n(.*?)(?=^## \[|\Z)", text, re.M | re.S)
16+
if unreleased:
17+
body = unreleased.group(1).strip()
18+
if body:
19+
section = f"## [{ver}] - {date}\n\n{body}\n\n"
20+
else:
21+
section = (
22+
f"## [{ver}] - {date}\n\n"
23+
"### Changed\n\n"
24+
f"- Release {ver}\n\n"
25+
)
26+
return re.sub(
27+
r"^(## \[Unreleased\]\s*\n)(.*?)(?=^## \[|\Z)",
28+
lambda match: match.group(1) + "\n" + section,
29+
text,
30+
count=1,
31+
flags=re.M | re.S,
32+
)
33+
34+
section = (
35+
f"## [Unreleased]\n\n"
36+
f"## [{ver}] - {date}\n\n"
37+
"### Changed\n\n"
38+
f"- Release {ver}\n\n"
39+
)
40+
first_heading = re.search(r"^## \[", text, re.M)
41+
if first_heading:
42+
pos = first_heading.start()
43+
return text[:pos] + section + text[pos:]
44+
return text.rstrip() + "\n\n" + section
45+
46+
47+
def update_changelog_file(path: Path, ver: str, date: str) -> None:
48+
if not path.exists():
49+
path.write_text(
50+
"# Changelog\n\n"
51+
"All notable changes to this project will be documented in this file.\n\n"
52+
"The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\n"
53+
"and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n"
54+
f"## [Unreleased]\n\n"
55+
f"## [{ver}] - {date}\n\n"
56+
"### Changed\n\n"
57+
f"- Release {ver}\n"
58+
)
59+
return
60+
61+
path.write_text(update_changelog_text(path.read_text(), ver, date))
62+
63+
64+
def main() -> None:
65+
if len(sys.argv) != 3:
66+
raise SystemExit("usage: update_changelog.py VERSION YYYY-MM-DD")
67+
update_changelog_file(Path("CHANGELOG.md"), sys.argv[1], sys.argv[2])
68+
69+
70+
if __name__ == "__main__":
71+
main()

tests/test_update_changelog.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from scripts.update_changelog import update_changelog_text
2+
3+
HEADER = """# Changelog
4+
5+
All notable changes to this project will be documented in this file.
6+
7+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
8+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
9+
10+
## [Unreleased]
11+
12+
## [0.1.1] - 2026-05-19
13+
14+
### Added
15+
16+
- Initial feature.
17+
"""
18+
19+
20+
def test_empty_unreleased_inserts_version_without_duplicate_heading():
21+
result = update_changelog_text(HEADER, "0.1.2", "2026-05-20")
22+
assert result.count("## [Unreleased]") == 1
23+
assert "## [0.1.2] - 2026-05-20" in result
24+
assert "The format is based on [Keep a Changelog]" in result.split("## [0.1.2]")[0]
25+
assert result.index("## [0.1.2]") < result.index("## [0.1.1]")
26+
27+
28+
def test_populated_unreleased_moves_notes_into_new_section():
29+
text = HEADER.replace(
30+
"## [Unreleased]\n\n",
31+
"## [Unreleased]\n\n### Added\n\n- New widget.\n\n",
32+
)
33+
result = update_changelog_text(text, "0.1.2", "2026-05-20")
34+
assert result.count("## [Unreleased]") == 1
35+
assert "- New widget." in result
36+
assert result.index("- New widget.") < result.index("## [0.1.1]")
37+
assert "The format is based on [Keep a Changelog]" in result.split("## [0.1.2]")[0]

0 commit comments

Comments
 (0)