Skip to content

Commit 0b1108f

Browse files
committed
Automate release process with workflow_dispatch
Add release.yml workflow that handles the full release: prepare changelog, commit, tag, build, test, publish to PyPI, create GitHub release, and start next development cycle. Move publish jobs out of test.yml (CI only tests now). Update RELEASING.rst with instructions to run the workflow.
1 parent 47d046f commit 0b1108f

4 files changed

Lines changed: 177 additions & 87 deletions

File tree

.github/workflows/release.yml

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
name: Release
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
bump:
7+
description: "Version bump (ignored if version is set)"
8+
required: false
9+
type: choice
10+
default: minor
11+
options:
12+
- major
13+
- minor
14+
- micro
15+
- post
16+
version:
17+
description: "Exact version (e.g. 1.0rc1). Overrides bump."
18+
required: false
19+
20+
jobs:
21+
release:
22+
runs-on: ubuntu-latest
23+
environment: release
24+
permissions:
25+
contents: write
26+
id-token: write
27+
steps:
28+
- uses: actions/checkout@v6
29+
with:
30+
fetch-depth: 0
31+
32+
- name: Configure git
33+
run: |
34+
git config user.name "github-actions[bot]"
35+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
36+
37+
- name: Prepare changelog
38+
id: version
39+
run: |
40+
if [ -n "${{ github.event.inputs.version }}" ]; then
41+
version=$(python make_changelog.py "${{ github.event.inputs.version }}")
42+
else
43+
version=$(python make_changelog.py --${{ github.event.inputs.bump }})
44+
fi
45+
echo "version=$version" >> "$GITHUB_OUTPUT"
46+
echo "tag=v$version" >> "$GITHUB_OUTPUT"
47+
48+
- name: Commit and tag
49+
run: |
50+
git commit -am "Prepare release ${{ steps.version.outputs.version }}"
51+
git tag "${{ steps.version.outputs.tag }}"
52+
git push origin "${{ steps.version.outputs.tag }}"
53+
54+
- name: Build
55+
id: build
56+
uses: hynek/build-and-inspect-python-package@v2.14
57+
with:
58+
upload-name-suffix: -release
59+
60+
- name: Create GitHub Release
61+
env:
62+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
63+
run: |
64+
python -c "
65+
import re, sys
66+
text = open('CHANGELOG.rst').read()
67+
ver = '${{ steps.version.outputs.version }}'
68+
m = re.search(rf'^{re.escape(ver)}\n-+\n', text, re.MULTILINE)
69+
if not m: sys.exit('version section not found')
70+
rest = text[m.end():]
71+
nxt = re.search(r'^\S+\n[-=]+\n', rest, re.MULTILINE)
72+
body = rest[:nxt.start()].strip() if nxt else rest.strip()
73+
open('/tmp/release-notes.md', 'w').write(body)
74+
"
75+
gh release create "${{ steps.version.outputs.tag }}" \
76+
--notes-file /tmp/release-notes.md \
77+
${{ steps.build.outputs.dist }}/*
78+
79+
- name: Publish to PyPI
80+
uses: pypa/gh-action-pypi-publish@v1.13.0
81+
with:
82+
attestations: true
83+
packages-dir: ${{ steps.build.outputs.dist }}
84+
85+
- name: Start next development cycle
86+
run: |
87+
python make_changelog.py UNRELEASED
88+
git commit -am "Start next development cycle"
89+
git push origin main

.github/workflows/test.yml

Lines changed: 0 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -79,45 +79,3 @@ jobs:
7979
shell: bash
8080
run: |
8181
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/*

RELEASING.rst

Lines changed: 21 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -8,59 +8,39 @@ Versions are derived automatically from git tags.
88
.. _setuptools-scm: https://github.com/pypa/setuptools-scm
99

1010

11-
Version
12-
-------
11+
Relative bump
12+
-------------
1313

14-
``main`` should always be green and a potential release candidate.
15-
``unittest2pytest`` follows semantic versioning, so given that the
16-
current version is ``X.Y.Z``, to find the next version number one
17-
needs to look at the ``CHANGELOG.rst`` file:
14+
Run the **Release** workflow, selecting the version bump type:
1815

19-
- If there any new feature, then we must make a new **minor** release:
20-
next release will be ``X.Y+1.0``.
16+
.. code-block:: console
2117
22-
- Otherwise it is just a **bug fix** release: ``X.Y.Z+1``.
18+
gh workflow run release.yml -R pytest-dev/unittest2pytest --field bump=minor
2319
20+
Options: ``major``, ``minor``, ``micro``, ``post``. The version is
21+
computed automatically from the latest git tag.
2422

25-
Steps
26-
-----
2723

28-
To publish a new release ``X.Y.Z``, the steps are as follows:
24+
Absolute version
25+
----------------
2926

30-
#. Update ``CHANGELOG.rst``:
27+
To release a specific version (e.g. a release candidate):
3128

32-
.. code-block:: console
29+
.. code-block:: console
3330
34-
python make_changelog.py X.Y.Z
31+
gh workflow run release.yml -R pytest-dev/unittest2pytest --field version=1.0rc1
3532
36-
This replaces the ``UNRELEASED`` section with a dated ``X.Y.Z``
37-
section. Review the result and add any missing entries before
38-
committing.
3933
40-
#. Commit and push:
34+
What the workflow does
35+
----------------------
4136

42-
.. code-block:: console
43-
44-
git commit -am "Prepare release X.Y.Z"
45-
git push origin main
46-
47-
#. Tag and push:
48-
49-
.. code-block:: console
50-
51-
git tag -s vX.Y.Z -m "unittest2pytest X.Y.Z"
52-
git push origin vX.Y.Z
53-
54-
Pushing the tag triggers the CI workflow, which builds, tests,
55-
publishes to PyPI, and creates a GitHub release.
56-
57-
#. Start the next development cycle:
58-
59-
.. code-block:: console
60-
61-
python make_changelog.py UNRELEASED
62-
git commit -am "Start next development cycle"
63-
git push origin main
37+
#. Runs ``make_changelog.py`` to replace the ``UNRELEASED`` section
38+
with the version number and today's date.
39+
#. Commits, tags, and pushes.
40+
#. Builds the package.
41+
#. Creates a GitHub Release with the built artifacts.
42+
#. Publishes to PyPI (requires the ``release`` environment).
43+
#. Runs ``make_changelog.py UNRELEASED`` and pushes a follow-up commit.
6444

6545

6646
How versioning works

make_changelog.py

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,27 @@
33
make_changelog.py — Update CHANGELOG.rst for releases.
44
55
Usage:
6-
python make_changelog.py <version> Replace UNRELEASED with a dated section.
6+
python make_changelog.py 0.6 Replace UNRELEASED with a dated section.
7+
python make_changelog.py --minor Bump minor version from latest tag.
8+
python make_changelog.py --major Bump major version from latest tag.
9+
python make_changelog.py --micro Bump micro version from latest tag.
10+
python make_changelog.py --post Bump post version from latest tag.
711
python make_changelog.py UNRELEASED Add a new UNRELEASED section.
812
"""
913

1014
from __future__ import annotations
1115

1216
import argparse
1317
import re
18+
import subprocess
1419
import sys
1520
from datetime import date
1621
from pathlib import Path
1722

23+
from packaging.version import Version
24+
1825
CHANGELOG = Path(__file__).resolve().parent / "CHANGELOG.rst"
26+
REPO_ROOT = CHANGELOG.parent
1927

2028
UNRELEASED_SECTION = """\
2129
UNRELEASED
@@ -26,6 +34,42 @@
2634
"""
2735

2836

37+
def _latest_tag_version() -> Version:
38+
"""Get the latest vX.Y.Z tag as a packaging Version."""
39+
result = subprocess.run(
40+
["git", "tag", "-l", "v*", "--sort=-v:refname"],
41+
capture_output=True,
42+
text=True,
43+
cwd=REPO_ROOT,
44+
)
45+
for line in result.stdout.strip().splitlines():
46+
tag = line.strip().removeprefix("v")
47+
try:
48+
return Version(tag)
49+
except Exception:
50+
continue
51+
print("ERROR: No version tags found", file=sys.stderr)
52+
sys.exit(1)
53+
54+
55+
def _bump_version(bump: str) -> str:
56+
"""Compute the next version string from the latest tag."""
57+
v = _latest_tag_version()
58+
59+
if bump == "major":
60+
return f"{v.major + 1}.0"
61+
elif bump == "minor":
62+
return f"{v.major}.{v.minor + 1}"
63+
elif bump == "micro":
64+
return f"{v.major}.{v.minor}.{v.micro + 1}"
65+
elif bump == "post":
66+
post = (v.post or 0) + 1
67+
base = f"{v.major}.{v.minor}.{v.micro}" if v.micro else f"{v.major}.{v.minor}"
68+
return f"{base}.post{post}"
69+
else:
70+
raise ValueError(f"Unknown bump: {bump}")
71+
72+
2973
def add_unreleased() -> int:
3074
text = CHANGELOG.read_text()
3175

@@ -66,18 +110,37 @@ def cut_release(version: str) -> int:
66110
new_text = text[: match.start()] + replacement + text[match.end() :]
67111
CHANGELOG.write_text(new_text)
68112

69-
print(f"CHANGELOG.rst updated: UNRELEASED → {version} ({today})")
113+
print(f"CHANGELOG.rst updated: UNRELEASED → {version} ({today})", file=sys.stderr)
114+
print(version)
70115
return 0
71116

72117

73118
def main() -> int:
74119
parser = argparse.ArgumentParser(description=__doc__)
75-
parser.add_argument("version", help="Release version (e.g. 0.6) or UNRELEASED")
120+
parser.add_argument("version", nargs="?", help="Release version (e.g. 0.6) or UNRELEASED")
121+
122+
bump = parser.add_mutually_exclusive_group()
123+
bump.add_argument("--major", action="store_const", const="major", dest="bump")
124+
bump.add_argument("--minor", action="store_const", const="minor", dest="bump")
125+
bump.add_argument("--micro", action="store_const", const="micro", dest="bump")
126+
bump.add_argument("--post", action="store_const", const="post", dest="bump")
127+
76128
args = parser.parse_args()
77129

130+
if args.version and args.bump:
131+
parser.error("Cannot specify both a version and a bump flag")
132+
133+
if args.bump:
134+
version = _bump_version(args.bump)
135+
return cut_release(version)
136+
78137
if args.version == "UNRELEASED":
79138
return add_unreleased()
80-
return cut_release(args.version)
139+
140+
if args.version:
141+
return cut_release(args.version)
142+
143+
parser.error("Provide a version, UNRELEASED, or a bump flag (--major/--minor/--micro/--post)")
81144

82145

83146
if __name__ == "__main__":

0 commit comments

Comments
 (0)