Skip to content

Commit a1c2233

Browse files
committed
Replace zest.releaser with release and changelog tooling
- Remove [tool.zest-releaser] from pyproject.toml - Remove zest.releaser and check-manifest from tests/requirements.txt - Add make_changelog.py for preparing releases and adding UNRELEASED - Add check_changelog.py CI check (diffs UNRELEASED against main on PRs, verifies version section on tags) - Add RELEASE_PROCESS.md documenting the tag-based release flow - Merge deploy workflow into CI: publish is gated on tests passing - Add MANIFEST.in to exclude dev files from sdist
1 parent cfd3671 commit a1c2233

8 files changed

Lines changed: 315 additions & 57 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 0 additions & 48 deletions
This file was deleted.

.github/workflows/test.yml

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
name: Test
1+
name: CI
22

33
on:
44
push:
55
branches:
66
- main
77
- "test-me-*"
8+
tags:
9+
- "v*"
810

911
pull_request:
1012
branches:
@@ -19,10 +21,28 @@ concurrency:
1921
cancel-in-progress: true
2022

2123
jobs:
24+
changelog:
25+
runs-on: ubuntu-latest
26+
steps:
27+
- uses: actions/checkout@v6
28+
29+
- name: Check changelog (tagged release)
30+
if: github.ref_type == 'tag'
31+
run: python check_changelog.py --tag "${{ github.ref_name }}"
32+
33+
- name: Check changelog (PR has new entries)
34+
if: github.event_name == 'pull_request'
35+
run: |
36+
git fetch origin main --depth=1
37+
python check_changelog.py
38+
2239
package:
2340
runs-on: ubuntu-latest
2441
steps:
2542
- uses: actions/checkout@v6
43+
with:
44+
fetch-depth: 0
45+
2646
- name: Build and Check Package
2747
uses: hynek/build-and-inspect-python-package@v2.17
2848

@@ -59,3 +79,45 @@ jobs:
5979
shell: bash
6080
run: |
6181
tox run -e py --installpkg `find dist/*.tar.gz`
82+
83+
pypi-publish:
84+
if: github.ref_type == 'tag'
85+
needs: [changelog, package, test]
86+
runs-on: ubuntu-latest
87+
environment: release
88+
permissions:
89+
id-token: write
90+
91+
steps:
92+
- name: Download Package
93+
uses: actions/download-artifact@v8
94+
with:
95+
name: Packages
96+
path: dist
97+
98+
- name: Publish to PyPI
99+
uses: pypa/gh-action-pypi-publish@v1.13.0
100+
with:
101+
attestations: true
102+
103+
github-release:
104+
if: github.ref_type == 'tag'
105+
needs: [pypi-publish]
106+
runs-on: ubuntu-latest
107+
permissions:
108+
contents: write
109+
110+
steps:
111+
- uses: actions/checkout@v6
112+
113+
- name: Download Package
114+
uses: actions/download-artifact@v8
115+
with:
116+
name: Packages
117+
path: dist
118+
119+
- name: Create GitHub Release
120+
env:
121+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
122+
run: |
123+
gh release create "${{ github.ref_name }}" --generate-notes dist/*

MANIFEST.in

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
exclude .gitignore
2+
exclude RELEASE_PROCESS.md
3+
exclude make_changelog.py
4+
exclude check_changelog.py
5+
exclude DEVELOPER.rst
6+
exclude RELEASING.rst
7+
exclude tox.ini
8+
prune .github

RELEASE_PROCESS.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Release Process
2+
3+
unittest2pytest uses [setuptools-scm](https://github.com/pypa/setuptools-scm)
4+
for version management. Versions are derived automatically from git tags.
5+
6+
## Cutting a Release
7+
8+
1. **Update `CHANGELOG.rst`:**
9+
10+
```
11+
python make_changelog.py X.Y.Z
12+
git commit -am "Prepare release X.Y.Z"
13+
git push origin main
14+
```
15+
16+
This replaces the `UNRELEASED` section with a dated `X.Y.Z` section.
17+
Review the result and add any missing entries before committing.
18+
19+
2. **Tag and push:**
20+
21+
```
22+
git tag -s vX.Y.Z -m "unittest2pytest X.Y.Z"
23+
git push origin vX.Y.Z
24+
```
25+
26+
Pushing the tag triggers the release workflow, which builds,
27+
publishes to PyPI, and creates a GitHub release.
28+
29+
3. **Start the next development cycle:**
30+
31+
```
32+
python make_changelog.py UNRELEASED
33+
git commit -am "Start next development cycle"
34+
git push origin main
35+
```
36+
37+
## How Versioning Works
38+
39+
- Tagged commits (e.g. `v0.6`) produce version `0.6`.
40+
- Commits after a tag produce dev versions like `0.7.dev3+gabcdef`.
41+
- The version is written to `unittest2pytest/_version.py` at build time.
42+
This file is git-ignored and should not be committed.

check_changelog.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/usr/bin/env python3
2+
"""
3+
check_changelog.py — Verify CHANGELOG.rst is up to date.
4+
5+
Usage:
6+
python check_changelog.py Check UNRELEASED has new entries vs main.
7+
python check_changelog.py --tag v0.6 Check a version section exists for the tag.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import argparse
13+
import re
14+
import subprocess
15+
import sys
16+
from pathlib import Path
17+
18+
CHANGELOG = Path(__file__).resolve().parent / "CHANGELOG.rst"
19+
REPO_ROOT = CHANGELOG.parent
20+
21+
22+
def _unreleased_content(text: str) -> str | None:
23+
"""Extract the body of the UNRELEASED section, or None if missing."""
24+
header = re.search(
25+
r"^UNRELEASED\n-+\n\n\*UNRELEASED\*\n",
26+
text,
27+
re.MULTILINE,
28+
)
29+
if not header:
30+
return None
31+
rest = text[header.end() :]
32+
next_section = re.search(r"^\S+\n[-=]+\n", rest, re.MULTILINE)
33+
content = rest[: next_section.start()] if next_section else rest
34+
return content.strip()
35+
36+
37+
def _main_changelog() -> str | None:
38+
"""Read CHANGELOG.rst from the main branch, or None if unavailable."""
39+
for ref in ("origin/main", "main"):
40+
result = subprocess.run(
41+
["git", "show", f"{ref}:CHANGELOG.rst"],
42+
capture_output=True,
43+
text=True,
44+
cwd=REPO_ROOT,
45+
)
46+
if result.returncode == 0:
47+
return result.stdout
48+
return None
49+
50+
51+
def check_unreleased() -> int:
52+
text = CHANGELOG.read_text()
53+
54+
current = _unreleased_content(text)
55+
if current is None:
56+
print("ERROR: No UNRELEASED section found", file=sys.stderr)
57+
return 1
58+
59+
if not current:
60+
print("ERROR: UNRELEASED section is empty — add a changelog entry", file=sys.stderr)
61+
return 1
62+
63+
main_text = _main_changelog()
64+
if main_text is not None:
65+
main_content = _unreleased_content(main_text)
66+
if current == (main_content or ""):
67+
print(
68+
"ERROR: UNRELEASED section is unchanged from main — add a changelog entry",
69+
file=sys.stderr,
70+
)
71+
return 1
72+
73+
print("OK: UNRELEASED section has new content")
74+
return 0
75+
76+
77+
def check_tag(tag: str) -> int:
78+
text = CHANGELOG.read_text()
79+
80+
# Strip leading v from tag
81+
version = tag.removeprefix("v")
82+
83+
# Look for a section matching this version
84+
pattern = re.compile(
85+
rf"^{re.escape(version)}\n-+\n",
86+
re.MULTILINE,
87+
)
88+
if not pattern.search(text):
89+
print(
90+
f"ERROR: No changelog section found for {version}",
91+
file=sys.stderr,
92+
)
93+
return 1
94+
95+
# UNRELEASED should not be present in a tagged release
96+
if re.search(r"^UNRELEASED\n-+\n", text, re.MULTILINE):
97+
print(
98+
"ERROR: UNRELEASED section still present in tagged release",
99+
file=sys.stderr,
100+
)
101+
return 1
102+
103+
print(f"OK: Changelog has section for {version}")
104+
return 0
105+
106+
107+
def main() -> int:
108+
parser = argparse.ArgumentParser(description=__doc__)
109+
parser.add_argument("--tag", help="Git tag to check against (e.g. v0.6)")
110+
args = parser.parse_args()
111+
112+
if args.tag:
113+
return check_tag(args.tag)
114+
return check_unreleased()
115+
116+
117+
if __name__ == "__main__":
118+
sys.exit(main())

make_changelog.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
#!/usr/bin/env python3
2+
"""
3+
make_changelog.py — Update CHANGELOG.rst for releases.
4+
5+
Usage:
6+
python make_changelog.py <version> Replace UNRELEASED with a dated section.
7+
python make_changelog.py UNRELEASED Add a new UNRELEASED section.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import argparse
13+
import re
14+
import sys
15+
from datetime import date
16+
from pathlib import Path
17+
18+
CHANGELOG = Path(__file__).resolve().parent / "CHANGELOG.rst"
19+
20+
UNRELEASED_SECTION = """\
21+
UNRELEASED
22+
----------
23+
24+
*UNRELEASED*
25+
26+
"""
27+
28+
29+
def add_unreleased() -> int:
30+
text = CHANGELOG.read_text()
31+
32+
if re.search(r"^UNRELEASED\n-+\n", text, re.MULTILINE):
33+
print("ERROR: UNRELEASED section already exists", file=sys.stderr)
34+
return 1
35+
36+
# Insert after the top-level heading
37+
match = re.search(r"^(Changelog\n=+\n)\n", text, re.MULTILINE)
38+
if not match:
39+
print("ERROR: Could not find Changelog heading", file=sys.stderr)
40+
return 1
41+
42+
insert_at = match.end()
43+
new_text = text[:insert_at] + UNRELEASED_SECTION + "\n" + text[insert_at:]
44+
CHANGELOG.write_text(new_text)
45+
46+
print("CHANGELOG.rst updated: added UNRELEASED section")
47+
return 0
48+
49+
50+
def cut_release(version: str) -> int:
51+
today = date.today().strftime("%Y-%m-%d")
52+
text = CHANGELOG.read_text()
53+
54+
pattern = re.compile(
55+
r"^UNRELEASED\n-+\n\n\*UNRELEASED\*\n",
56+
re.MULTILINE,
57+
)
58+
match = pattern.search(text)
59+
if not match:
60+
print("ERROR: Could not find UNRELEASED section in CHANGELOG.rst", file=sys.stderr)
61+
return 1
62+
63+
underline = "-" * len(version)
64+
replacement = f"{version}\n{underline}\n\n*{today}*\n"
65+
66+
new_text = text[: match.start()] + replacement + text[match.end() :]
67+
CHANGELOG.write_text(new_text)
68+
69+
print(f"CHANGELOG.rst updated: UNRELEASED → {version} ({today})")
70+
return 0
71+
72+
73+
def main() -> int:
74+
parser = argparse.ArgumentParser(description=__doc__)
75+
parser.add_argument("version", help="Release version (e.g. 0.6) or UNRELEASED")
76+
args = parser.parse_args()
77+
78+
if args.version == "UNRELEASED":
79+
return add_unreleased()
80+
return cut_release(args.version)
81+
82+
83+
if __name__ == "__main__":
84+
sys.exit(main())

0 commit comments

Comments
 (0)