Skip to content

Commit a8f2cbc

Browse files
committed
feat: automate ZeroVer release versioning (#28)
1 parent 8ea25fd commit a8f2cbc

7 files changed

Lines changed: 409 additions & 0 deletions

File tree

.github/workflows/release.yaml

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
name: Prepare Release
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
8+
concurrency:
9+
group: prepare-release-${{ github.ref }}
10+
cancel-in-progress: false
11+
12+
jobs:
13+
prepare-release:
14+
name: Prepare ZeroVer Release
15+
if: ${{ github.actor != 'github-actions[bot]' && !contains(github.event.head_commit.message, 'chore(release):') }}
16+
runs-on: ubuntu-latest
17+
permissions:
18+
contents: write
19+
pull-requests: read
20+
steps:
21+
- name: Checkout code
22+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
23+
with:
24+
fetch-depth: 0
25+
26+
- name: Set up Python
27+
uses: actions/setup-python@42375524a0d71fce93d5f68f63e4bcdf9689ff89
28+
with:
29+
python-version: "3.14"
30+
31+
- name: Resolve release signal
32+
id: signal
33+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea
34+
with:
35+
script: |
36+
const prs = await github.paginate(
37+
github.rest.repos.listPullRequestsAssociatedWithCommit,
38+
{
39+
owner: context.repo.owner,
40+
repo: context.repo.repo,
41+
commit_sha: context.sha,
42+
},
43+
);
44+
45+
const mergedPr = prs.find((pr) => pr.merged_at && pr.base.ref === "main");
46+
if (!mergedPr) {
47+
core.info(`No merged pull request found for commit ${context.sha}; skipping release.`);
48+
core.setOutput("release_kind", "none");
49+
return;
50+
}
51+
52+
const labels = mergedPr.labels.map((label) => label.name);
53+
const hasPatch = labels.includes("release:patch");
54+
const hasMinor = labels.includes("release:minor");
55+
56+
if (hasPatch && hasMinor) {
57+
core.setFailed(
58+
`Pull request #${mergedPr.number} cannot carry both release:patch and release:minor labels.`,
59+
);
60+
return;
61+
}
62+
63+
const releaseKind = hasMinor ? "minor" : hasPatch ? "patch" : "none";
64+
core.info(`Resolved release signal ${releaseKind} from pull request #${mergedPr.number}.`);
65+
core.setOutput("release_kind", releaseKind);
66+
core.setOutput("pr_number", String(mergedPr.number));
67+
68+
- name: Prepare version
69+
id: version
70+
if: ${{ steps.signal.outputs.release_kind != 'none' }}
71+
env:
72+
PYTHONPATH: src
73+
run: |
74+
version="$(python scripts/prepare_release.py --release-kind "${{ steps.signal.outputs.release_kind }}")"
75+
echo "version=${version}" >> "$GITHUB_OUTPUT"
76+
77+
- name: Commit release version
78+
if: ${{ steps.signal.outputs.release_kind != 'none' }}
79+
run: |
80+
git config user.name "github-actions[bot]"
81+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
82+
git add pyproject.toml
83+
if git diff --cached --quiet; then
84+
echo "pyproject.toml already contains version ${{ steps.version.outputs.version }}"
85+
exit 0
86+
fi
87+
git commit -m "chore(release): prepare v${{ steps.version.outputs.version }} [skip ci]"
88+
git push origin HEAD:main
89+
90+
- name: Create release tag
91+
if: ${{ steps.signal.outputs.release_kind != 'none' }}
92+
run: |
93+
git fetch --tags
94+
if git rev-parse "v${{ steps.version.outputs.version }}" >/dev/null 2>&1; then
95+
echo "Tag v${{ steps.version.outputs.version }} already exists."
96+
exit 0
97+
fi
98+
git tag -a "v${{ steps.version.outputs.version }}" -m "Release v${{ steps.version.outputs.version }}"
99+
git push origin "v${{ steps.version.outputs.version }}"

scripts/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Repository automation scripts."""

scripts/prepare_release.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Prepare the next ZeroVer release version."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import subprocess
7+
from pathlib import Path
8+
9+
from schnee.release.versioning import (
10+
determine_next_version,
11+
find_latest_release_tag,
12+
write_pyproject_version,
13+
)
14+
15+
16+
def _list_release_tags() -> list[str]:
17+
"""Return release tags visible from the current repository."""
18+
completed = subprocess.run(
19+
["/usr/bin/git", "tag", "--list", "v0.*"],
20+
check=True,
21+
capture_output=True,
22+
text=True,
23+
)
24+
return [line for line in completed.stdout.splitlines() if line]
25+
26+
27+
def _parse_args() -> argparse.Namespace:
28+
"""Parse CLI arguments."""
29+
parser = argparse.ArgumentParser()
30+
parser.add_argument(
31+
"--pyproject",
32+
type=Path,
33+
default=Path("pyproject.toml"),
34+
help="Path to the pyproject.toml file to update.",
35+
)
36+
parser.add_argument(
37+
"--release-kind",
38+
choices=("patch", "minor"),
39+
required=True,
40+
help="Type of ZeroVer release to prepare.",
41+
)
42+
return parser.parse_args()
43+
44+
45+
def main() -> int:
46+
"""Prepare and print the next release version."""
47+
args = _parse_args()
48+
latest_tag = find_latest_release_tag(_list_release_tags())
49+
version = determine_next_version(
50+
latest_release_tag=latest_tag,
51+
release_kind=args.release_kind,
52+
)
53+
write_pyproject_version(args.pyproject, version)
54+
print(version)
55+
return 0
56+
57+
58+
if __name__ == "__main__":
59+
raise SystemExit(main())

src/schnee/release/__init__.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Release automation helpers."""
2+
3+
from schnee.release.versioning import (
4+
INITIAL_RELEASE_VERSION,
5+
ReleaseKind,
6+
determine_next_version,
7+
find_latest_release_tag,
8+
parse_release_tag,
9+
parse_zerover,
10+
read_pyproject_version,
11+
write_pyproject_version,
12+
)
13+
14+
__all__ = [
15+
"INITIAL_RELEASE_VERSION",
16+
"ReleaseKind",
17+
"determine_next_version",
18+
"find_latest_release_tag",
19+
"parse_release_tag",
20+
"parse_zerover",
21+
"read_pyproject_version",
22+
"write_pyproject_version",
23+
]

src/schnee/release/versioning.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Helpers for ZeroVer release automation."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
from dataclasses import dataclass
7+
from typing import TYPE_CHECKING, Literal
8+
9+
if TYPE_CHECKING:
10+
from pathlib import Path
11+
12+
INITIAL_RELEASE_VERSION = "0.1.0"
13+
TAG_PREFIX = "v"
14+
ZEROVER_PART_COUNT = 3
15+
ReleaseKind = Literal["patch", "minor"]
16+
17+
_PYPROJECT_VERSION_PATTERN = re.compile(r'^version = "([^"]+)"$')
18+
19+
20+
@dataclass(frozen=True, order=True)
21+
class ZeroVer:
22+
"""A parsed ZeroVer version."""
23+
24+
minor: int
25+
patch: int
26+
27+
def __str__(self) -> str:
28+
"""Render the version as 0.Y.Z."""
29+
return f"0.{self.minor}.{self.patch}"
30+
31+
32+
def parse_zerover(value: str) -> ZeroVer:
33+
"""Parse a ZeroVer version string."""
34+
parts = value.split(".")
35+
if len(parts) != ZEROVER_PART_COUNT:
36+
msg = f"expected a 0.Y.Z version, got {value!r}"
37+
raise ValueError(msg)
38+
39+
major, minor_text, patch_text = parts
40+
if major != "0" or not minor_text.isdigit() or not patch_text.isdigit():
41+
msg = f"expected a 0.Y.Z version, got {value!r}"
42+
raise ValueError(msg)
43+
44+
return ZeroVer(minor=int(minor_text), patch=int(patch_text))
45+
46+
47+
def parse_release_tag(tag: str) -> ZeroVer:
48+
"""Parse a release tag of the form v0.Y.Z."""
49+
if not tag.startswith(TAG_PREFIX):
50+
msg = f"expected a release tag starting with {TAG_PREFIX!r}, got {tag!r}"
51+
raise ValueError(msg)
52+
53+
return parse_zerover(tag.removeprefix(TAG_PREFIX))
54+
55+
56+
def find_latest_release_tag(tags: list[str]) -> str | None:
57+
"""Return the highest ZeroVer release tag from a list of tags."""
58+
release_tags = [tag for tag in tags if tag.startswith(TAG_PREFIX)]
59+
if not release_tags:
60+
return None
61+
62+
_, latest_tag = max(
63+
((parse_release_tag(tag), tag) for tag in release_tags),
64+
key=lambda item: item[0],
65+
)
66+
return latest_tag
67+
68+
69+
def determine_next_version(
70+
latest_release_tag: str | None,
71+
release_kind: ReleaseKind,
72+
) -> str:
73+
"""Determine the next release version from the latest release tag."""
74+
if latest_release_tag is None:
75+
return INITIAL_RELEASE_VERSION
76+
77+
latest_version = parse_release_tag(latest_release_tag)
78+
if release_kind == "minor":
79+
return str(ZeroVer(minor=latest_version.minor + 1, patch=0))
80+
81+
return str(ZeroVer(minor=latest_version.minor, patch=latest_version.patch + 1))
82+
83+
84+
def read_pyproject_version(pyproject_path: Path) -> str:
85+
"""Read project.version from pyproject.toml."""
86+
project_section_found = False
87+
88+
for line in pyproject_path.read_text(encoding="utf-8").splitlines():
89+
stripped = line.strip()
90+
if stripped == "[project]":
91+
project_section_found = True
92+
continue
93+
94+
if project_section_found and stripped.startswith("["):
95+
break
96+
97+
if project_section_found:
98+
match = _PYPROJECT_VERSION_PATTERN.match(stripped)
99+
if match is not None:
100+
return match.group(1)
101+
102+
msg = f"could not find [project].version in {pyproject_path}"
103+
raise ValueError(msg)
104+
105+
106+
def write_pyproject_version(pyproject_path: Path, version: str) -> None:
107+
"""Write project.version in pyproject.toml."""
108+
parse_zerover(version)
109+
lines = pyproject_path.read_text(encoding="utf-8").splitlines()
110+
project_section_found = False
111+
112+
for index, line in enumerate(lines):
113+
stripped = line.strip()
114+
if stripped == "[project]":
115+
project_section_found = True
116+
continue
117+
118+
if project_section_found and stripped.startswith("["):
119+
break
120+
121+
if project_section_found and _PYPROJECT_VERSION_PATTERN.match(stripped):
122+
lines[index] = f'version = "{version}"'
123+
pyproject_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
124+
return
125+
126+
msg = f"could not find [project].version in {pyproject_path}"
127+
raise ValueError(msg)

tests/schnee/release/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

0 commit comments

Comments
 (0)