Skip to content

Commit 0f32b6f

Browse files
committed
Automate monthly release process
1 parent 567afa7 commit 0f32b6f

6 files changed

Lines changed: 484 additions & 5 deletions

File tree

.github/release/prepare_release.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
#!/usr/bin/env python3
2+
import datetime
3+
import os
4+
import re
5+
import subprocess
6+
import sys
7+
8+
9+
def run_cmd(cmd_args):
10+
"""Runs a terminal command without shell=True to avoid injection risks."""
11+
result = subprocess.run(cmd_args, capture_output=True, text=True, check=True)
12+
return result.stdout.strip()
13+
14+
15+
def get_latest_tag():
16+
"""Gets the latest git tag reachable from HEAD."""
17+
return run_cmd(["git", "describe", "--tags", "--abbrev=0"])
18+
19+
20+
def parse_version(version_str):
21+
"""Parses a version string like YYYY.M.PATCH[-suffix] into a tuple of integers."""
22+
match = re.match(r"^(\d+)\.(\d+)\.(\d+)", version_str)
23+
if not match:
24+
raise ValueError(
25+
f"Version '{version_str}' does not match expected CalVer pattern YYYY.M.PATCH"
26+
)
27+
return tuple(map(int, match.groups()))
28+
29+
30+
def calculate_next_version(latest_tag):
31+
"""Calculates the next CalVer version based on the latest tag and current date."""
32+
latest_ver = parse_version(latest_tag)
33+
tag_year, tag_month, tag_patch = latest_ver
34+
35+
now = datetime.datetime.now(datetime.timezone.utc)
36+
current_year = now.year
37+
current_month = now.month
38+
39+
if tag_year == current_year and tag_month == current_month:
40+
# Same month, increment patch
41+
next_patch = tag_patch + 1
42+
else:
43+
# New month, reset patch to 0
44+
next_patch = 0
45+
46+
next_version_str = f"{current_year}.{current_month}.{next_patch}"
47+
next_ver = parse_version(next_version_str)
48+
49+
# Safety guard: Ensure we never release a version older or equal to the last one
50+
if next_ver <= latest_ver:
51+
raise ValueError(
52+
f"Calculated next version ({next_version_str}) is not newer than "
53+
f"the latest tag ({latest_tag}). Potential version regression!"
54+
)
55+
56+
return next_version_str
57+
58+
59+
def get_changelog_entries(latest_tag):
60+
"""Retrieves all non-merge commit subjects since the latest tag."""
61+
cmd_args = [
62+
"git",
63+
"log",
64+
f"{latest_tag}..HEAD",
65+
"--no-merges",
66+
"--pretty=format:* %s",
67+
]
68+
log_output = run_cmd(cmd_args)
69+
if not log_output:
70+
return ["* No changes (released in sync with fsspec)."]
71+
return log_output.split("\n")
72+
73+
74+
def update_changelog_file(changelog_path, version, entries):
75+
"""Inserts a new release section with version and commit logs into the changelog.rst file."""
76+
if not os.path.exists(changelog_path):
77+
raise FileNotFoundError(f"Changelog file not found at {changelog_path}")
78+
79+
with open(changelog_path, "r", encoding="utf-8") as f:
80+
content = f.read()
81+
82+
lines = content.split("\n")
83+
insert_idx = -1
84+
# Regex to match version header (e.g., "2026.4.0" or "2025.5.0post1")
85+
version_re = re.compile(r"^\d{4}\.\d+\.\d+\S*$")
86+
87+
for i in range(len(lines) - 1):
88+
if (
89+
version_re.match(lines[i])
90+
and lines[i + 1].startswith("---")
91+
and len(lines[i + 1]) >= len(lines[i])
92+
):
93+
insert_idx = i
94+
break
95+
96+
if insert_idx == -1:
97+
# If we couldn't find a version header, we might be in an empty or differently formatted file.
98+
# In this case, we raise an error.
99+
raise ValueError(
100+
"Could not find a valid version header in changelog to insert before."
101+
)
102+
103+
# Prepare the new section
104+
version_underline = "-" * len(version)
105+
new_section_lines = (
106+
[
107+
version,
108+
version_underline,
109+
"",
110+
]
111+
+ entries
112+
+ [""]
113+
)
114+
115+
# Insert the new section. We want to keep an empty line between sections.
116+
# The first version header we found should be pushed down.
117+
# We insert before the version line.
118+
updated_lines = lines[:insert_idx] + new_section_lines + lines[insert_idx:]
119+
120+
with open(changelog_path, "w", encoding="utf-8") as f:
121+
f.write("\n".join(updated_lines))
122+
123+
print(f"Successfully updated changelog with version {version}")
124+
125+
126+
def main():
127+
changelog_path = "docs/source/changelog.rst"
128+
129+
try:
130+
# 1. Retrieve the latest release tag from Git
131+
latest_tag = get_latest_tag()
132+
print(f"Latest tag found: {latest_tag}")
133+
134+
# 2. Calculate the next CalVer version and perform regression checks
135+
next_version = calculate_next_version(latest_tag)
136+
print(f"Calculated next version: {next_version}")
137+
138+
# 3. Fetch the changelog entries (non-merge commits) since the last tag
139+
entries = get_changelog_entries(latest_tag)
140+
print(f"Found {len(entries)} changelog entries.")
141+
142+
# 4. Update the changelog file in place with the new release section
143+
update_changelog_file(changelog_path, next_version, entries)
144+
145+
# 5. Output the version to GITHUB_ENV for downstream workflow consumption
146+
print(f"NEXT_VERSION={next_version}")
147+
if "GITHUB_ENV" in os.environ:
148+
with open(os.environ["GITHUB_ENV"], "a") as gh_env:
149+
gh_env.write(f"VERSION={next_version}\n")
150+
gh_env.write(f"BRANCH_NAME=release-{next_version}\n")
151+
152+
except Exception as e:
153+
print(f"Error: {e}", file=sys.stderr)
154+
sys.exit(1)
155+
156+
157+
if __name__ == "__main__":
158+
main()
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import datetime
2+
import os
3+
import subprocess
4+
import sys
5+
6+
import pytest
7+
8+
# Add the directory containing prepare_release.py to the path
9+
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
10+
import prepare_release
11+
12+
13+
def test_parse_version():
14+
assert prepare_release.parse_version("2026.4.0") == (2026, 4, 0)
15+
assert prepare_release.parse_version("2026.12.5-post1") == (2026, 12, 5)
16+
17+
with pytest.raises(ValueError, match="does not match expected CalVer pattern"):
18+
prepare_release.parse_version("v2026.4.0")
19+
with pytest.raises(ValueError, match="does not match expected CalVer pattern"):
20+
prepare_release.parse_version("abc")
21+
with pytest.raises(ValueError, match="does not match expected CalVer pattern"):
22+
prepare_release.parse_version("2026.4")
23+
24+
25+
def test_calculate_next_version(monkeypatch):
26+
class MockDatetime(datetime.datetime):
27+
@classmethod
28+
def now(cls, tz=None):
29+
return cls(2026, 4, 15, tzinfo=datetime.timezone.utc)
30+
31+
monkeypatch.setattr("prepare_release.datetime.datetime", MockDatetime)
32+
33+
# Same month: increment patch
34+
assert prepare_release.calculate_next_version("2026.4.0") == "2026.4.1"
35+
assert prepare_release.calculate_next_version("2026.4.5") == "2026.4.6"
36+
37+
# New month: reset patch to 0
38+
assert prepare_release.calculate_next_version("2026.3.5") == "2026.4.0"
39+
assert prepare_release.calculate_next_version("2025.12.10") == "2026.4.0"
40+
41+
# Regression guard: new version (2026.4.0) must be newer than latest tag (2026.5.0)
42+
with pytest.raises(ValueError, match="Potential version regression"):
43+
prepare_release.calculate_next_version("2026.5.0")
44+
45+
# Regression guard: new version (2026.4.0) must be newer than latest tag (2027.1.0)
46+
with pytest.raises(ValueError, match="Potential version regression"):
47+
prepare_release.calculate_next_version("2027.1.0")
48+
49+
50+
def test_get_latest_tag(monkeypatch):
51+
# Success case
52+
monkeypatch.setattr("prepare_release.run_cmd", lambda args: "2026.4.0")
53+
assert prepare_release.get_latest_tag() == "2026.4.0"
54+
55+
# Error case
56+
def mock_run_cmd_fail(args):
57+
raise subprocess.CalledProcessError(1, args, stderr="git error")
58+
59+
monkeypatch.setattr("prepare_release.run_cmd", mock_run_cmd_fail)
60+
with pytest.raises(subprocess.CalledProcessError):
61+
prepare_release.get_latest_tag()
62+
63+
64+
def test_get_changelog_entries(monkeypatch):
65+
# Success case with commits
66+
def mock_run_cmd_commits(args):
67+
assert args == [
68+
"git",
69+
"log",
70+
"2026.4.0..HEAD",
71+
"--no-merges",
72+
"--pretty=format:* %s",
73+
]
74+
return "* Commit 1 (abc1234)\n* Commit 2 (def5678)"
75+
76+
monkeypatch.setattr("prepare_release.run_cmd", mock_run_cmd_commits)
77+
entries = prepare_release.get_changelog_entries("2026.4.0")
78+
assert entries == ["* Commit 1 (abc1234)", "* Commit 2 (def5678)"]
79+
80+
# Success case with no commits (empty string)
81+
monkeypatch.setattr("prepare_release.run_cmd", lambda args: "")
82+
entries = prepare_release.get_changelog_entries("2026.4.0")
83+
assert entries == ["* No changes (released in sync with fsspec)."]
84+
85+
# Error case: a git failure must propagate, not be swallowed into an
86+
# empty/placeholder changelog.
87+
def mock_run_cmd_fail(args):
88+
raise subprocess.CalledProcessError(1, args, stderr="git log error")
89+
90+
monkeypatch.setattr("prepare_release.run_cmd", mock_run_cmd_fail)
91+
with pytest.raises(subprocess.CalledProcessError):
92+
prepare_release.get_changelog_entries("2026.4.0")
93+
94+
95+
def test_update_changelog_file(tmp_path):
96+
changelog = tmp_path / "changelog.rst"
97+
initial_content = """Changelog
98+
=========
99+
100+
2026.4.0
101+
--------
102+
103+
* Previous change
104+
"""
105+
changelog.write_text(initial_content, encoding="utf-8")
106+
107+
prepare_release.update_changelog_file(str(changelog), "2026.4.1", ["* New feature"])
108+
109+
expected_content = """Changelog
110+
=========
111+
112+
2026.4.1
113+
--------
114+
115+
* New feature
116+
117+
2026.4.0
118+
--------
119+
120+
* Previous change
121+
"""
122+
assert changelog.read_text(encoding="utf-8") == expected_content
123+
124+
125+
def test_update_changelog_file_with_suffix(tmp_path):
126+
changelog = tmp_path / "changelog.rst"
127+
initial_content = """Changelog
128+
=========
129+
130+
2025.5.0post1
131+
-------------
132+
133+
* Previous change
134+
"""
135+
changelog.write_text(initial_content, encoding="utf-8")
136+
137+
prepare_release.update_changelog_file(str(changelog), "2026.4.1", ["* New feature"])
138+
139+
expected_content = """Changelog
140+
=========
141+
142+
2026.4.1
143+
--------
144+
145+
* New feature
146+
147+
2025.5.0post1
148+
-------------
149+
150+
* Previous change
151+
"""
152+
assert changelog.read_text(encoding="utf-8") == expected_content
153+
154+
155+
def test_update_changelog_file_no_header(tmp_path):
156+
changelog = tmp_path / "changelog.rst"
157+
changelog.write_text("No header here", encoding="utf-8")
158+
159+
with pytest.raises(ValueError, match="Could not find a valid version header"):
160+
prepare_release.update_changelog_file(
161+
str(changelog), "2026.4.1", ["* New feature"]
162+
)
163+
164+
165+
def test_update_changelog_file_short_underline(tmp_path):
166+
changelog = tmp_path / "changelog.rst"
167+
initial_content = """Changelog
168+
=========
169+
170+
2026.4.0
171+
--
172+
173+
* Previous change
174+
"""
175+
changelog.write_text(initial_content, encoding="utf-8")
176+
177+
with pytest.raises(ValueError, match="Could not find a valid version header"):
178+
prepare_release.update_changelog_file(
179+
str(changelog), "2026.4.1", ["* New feature"]
180+
)

0 commit comments

Comments
 (0)