Skip to content

Commit c2dccc2

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

6 files changed

Lines changed: 458 additions & 5 deletions

File tree

.github/release/prepare_release.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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+
try:
18+
return run_cmd(["git", "describe", "--tags", "--abbrev=0"])
19+
except subprocess.CalledProcessError as e:
20+
print(f"Error getting latest tag: {e.stderr}", file=sys.stderr)
21+
raise
22+
23+
24+
def parse_version(version_str):
25+
"""Parses a version string like YYYY.M.PATCH[-suffix] into a tuple of integers."""
26+
match = re.match(r"^(\d+)\.(\d+)\.(\d+)", version_str)
27+
if not match:
28+
raise ValueError(
29+
f"Version '{version_str}' does not match expected CalVer pattern YYYY.M.PATCH"
30+
)
31+
return tuple(map(int, match.groups()))
32+
33+
34+
def calculate_next_version(latest_tag):
35+
"""Calculates the next CalVer version based on the latest tag and current date."""
36+
latest_ver = parse_version(latest_tag)
37+
tag_year, tag_month, tag_patch = latest_ver
38+
39+
now = datetime.datetime.now(datetime.timezone.utc)
40+
current_year = now.year
41+
current_month = now.month
42+
43+
if tag_year == current_year and tag_month == current_month:
44+
# Same month, increment patch
45+
next_patch = tag_patch + 1
46+
else:
47+
# New month, reset patch to 0
48+
next_patch = 0
49+
50+
next_version_str = f"{current_year}.{current_month}.{next_patch}"
51+
next_ver = parse_version(next_version_str)
52+
53+
# Safety guard: Ensure we never release a version older or equal to the last one
54+
if next_ver <= latest_ver:
55+
raise ValueError(
56+
f"Calculated next version ({next_version_str}) is not newer than "
57+
f"the latest tag ({latest_tag}). Potential version regression!"
58+
)
59+
60+
return next_version_str
61+
62+
63+
def get_changelog_entries(latest_tag):
64+
"""Retrieves all non-merge commit subjects since the latest tag."""
65+
cmd_args = [
66+
"git",
67+
"log",
68+
f"{latest_tag}..HEAD",
69+
"--no-merges",
70+
"--pretty=format:* %s",
71+
]
72+
log_output = run_cmd(cmd_args)
73+
if not log_output:
74+
return ["* No changes (released in sync with fsspec)."]
75+
return log_output.split("\n")
76+
77+
78+
def update_changelog_file(changelog_path, version, entries):
79+
"""Inserts a new release section with version and commit logs into the changelog.rst file."""
80+
if not os.path.exists(changelog_path):
81+
raise FileNotFoundError(f"Changelog file not found at {changelog_path}")
82+
83+
with open(changelog_path, "r", encoding="utf-8") as f:
84+
content = f.read()
85+
86+
lines = content.split("\n")
87+
insert_idx = -1
88+
# Regex to match version header (e.g., "2026.4.0")
89+
version_re = re.compile(r"^\d{4}\.\d+\.\d+$")
90+
91+
for i in range(len(lines) - 1):
92+
if (
93+
version_re.match(lines[i])
94+
and lines[i + 1].startswith("---")
95+
and len(lines[i + 1]) >= len(lines[i])
96+
):
97+
insert_idx = i
98+
break
99+
100+
if insert_idx == -1:
101+
# If we couldn't find a version header, we might be in an empty or differently formatted file.
102+
# In this case, we raise an error.
103+
raise ValueError(
104+
"Could not find a valid version header in changelog to insert before."
105+
)
106+
107+
# Prepare the new section
108+
version_underline = "-" * len(version)
109+
new_section_lines = (
110+
[
111+
version,
112+
version_underline,
113+
"",
114+
]
115+
+ entries
116+
+ [""]
117+
)
118+
119+
# Insert the new section. We want to keep an empty line between sections.
120+
# The first version header we found should be pushed down.
121+
# We insert before the version line.
122+
updated_lines = lines[:insert_idx] + new_section_lines + lines[insert_idx:]
123+
124+
with open(changelog_path, "w", encoding="utf-8") as f:
125+
f.write("\n".join(updated_lines))
126+
127+
print(f"Successfully updated changelog with version {version}")
128+
129+
130+
def main():
131+
changelog_path = "docs/source/changelog.rst"
132+
133+
try:
134+
# 1. Retrieve the latest release tag from Git
135+
latest_tag = get_latest_tag()
136+
print(f"Latest tag found: {latest_tag}")
137+
138+
# 2. Calculate the next CalVer version and perform regression checks
139+
next_version = calculate_next_version(latest_tag)
140+
print(f"Calculated next version: {next_version}")
141+
142+
# 3. Fetch the changelog entries (non-merge commits) since the last tag
143+
entries = get_changelog_entries(latest_tag)
144+
print(f"Found {len(entries)} changelog entries.")
145+
146+
# 4. Update the changelog file in place with the new release section
147+
update_changelog_file(changelog_path, next_version, entries)
148+
149+
# 5. Output the version to GITHUB_ENV for downstream workflow consumption
150+
print(f"NEXT_VERSION={next_version}")
151+
if "GITHUB_ENV" in os.environ:
152+
with open(os.environ["GITHUB_ENV"], "a") as gh_env:
153+
gh_env.write(f"VERSION={next_version}\n")
154+
gh_env.write(f"BRANCH_NAME=release-{next_version}\n")
155+
156+
except Exception as e:
157+
print(f"Error: {e}", file=sys.stderr)
158+
sys.exit(1)
159+
160+
161+
if __name__ == "__main__":
162+
main()
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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_no_header(tmp_path):
126+
changelog = tmp_path / "changelog.rst"
127+
changelog.write_text("No header here", encoding="utf-8")
128+
129+
with pytest.raises(ValueError, match="Could not find a valid version header"):
130+
prepare_release.update_changelog_file(
131+
str(changelog), "2026.4.1", ["* New feature"]
132+
)
133+
134+
135+
def test_update_changelog_file_short_underline(tmp_path):
136+
changelog = tmp_path / "changelog.rst"
137+
initial_content = """Changelog
138+
=========
139+
140+
2026.4.0
141+
--
142+
143+
* Previous change
144+
"""
145+
changelog.write_text(initial_content, encoding="utf-8")
146+
147+
with pytest.raises(ValueError, match="Could not find a valid version header"):
148+
prepare_release.update_changelog_file(
149+
str(changelog), "2026.4.1", ["* New feature"]
150+
)
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
name: Release on Merge
2+
3+
# Monthly release orchestrator (Publishing Phase):
4+
# 1. Triggers when a Pull Request is closed and merged into main.
5+
# 2. If it is a release PR (has 'autorelease' label), it extracts the version.
6+
# 3. Creates and pushes the Git tag for that version.
7+
# 4. Calls the reusable release.yml workflow to build and publish to PyPI.
8+
on:
9+
pull_request:
10+
types: [closed]
11+
branches:
12+
- main
13+
14+
jobs:
15+
tag:
16+
# Run only if the PR was merged, and it has the 'autorelease' label,
17+
# and the title matches our release convention.
18+
if: |
19+
github.event.pull_request.merged == true &&
20+
contains(github.event.pull_request.labels.*.name, 'autorelease') &&
21+
startsWith(github.event.pull_request.title, 'chore: release ')
22+
23+
runs-on: ubuntu-latest
24+
permissions:
25+
contents: write
26+
outputs:
27+
version: ${{ steps.tagger.outputs.version }}
28+
29+
steps:
30+
- name: Checkout source
31+
uses: actions/checkout@v5
32+
with:
33+
fetch-depth: 0
34+
35+
- name: Configure Git
36+
run: |
37+
git config --global user.name "github-actions[bot]"
38+
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
39+
40+
- name: Create and Push Tag
41+
id: tagger
42+
env:
43+
PR_TITLE: ${{ github.event.pull_request.title }}
44+
run: |
45+
# Extract version from "chore: release YYYY.M.PATCH"
46+
VERSION=$(echo "$PR_TITLE" | sed 's/chore: release //')
47+
if [[ ! "$VERSION" =~ ^[0-9]{4}\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+)?$ ]]; then
48+
echo "Error: Extracted version '$VERSION' is invalid."
49+
exit 1
50+
fi
51+
echo "version=${VERSION}" >> $GITHUB_OUTPUT
52+
53+
echo "Creating and pushing tag for version: $VERSION"
54+
git tag -a "$VERSION" -m "Release $VERSION"
55+
git push origin "$VERSION"
56+
echo "Successfully pushed tag $VERSION"
57+
58+
publish:
59+
needs: tag
60+
permissions:
61+
contents: write
62+
id-token: write
63+
uses: ./.github/workflows/release.yml
64+
with:
65+
ref: ${{ needs.tag.outputs.version }}
66+
secrets:
67+
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

0 commit comments

Comments
 (0)