Skip to content

Commit 5cc32a6

Browse files
committed
👷 ci(release): simplify to one-button workflow (#1727)
* Simplify release flow to one-button workflow Adopt virtualenv-style release process: - One "Pre-release" workflow with version bump selector - Auto-generates version, runs towncrier, commits, tags, and pushes - Tag push triggers actual release workflow - Removes complex two-step PR-based flow Benefits: - Single button press to release - Auto version calculation based on changelog fragments - No manual PR creation/review needed - Simpler and faster release process Signed-off-by: Bernát Gábor <bgabor8@bloomberg.net> * 📝 docs(release): split zipapp build and upload in diagram Separating the zipapp build and upload into distinct steps makes the flow clearer and matches the actual workflow structure where building and uploading are separate GitHub Actions jobs. * 📝 docs(release): use sequence diagram and reorganize sections Replaced the flowchart with a proper mermaid sequence diagram that clearly shows the interaction between actors (Maintainer, GitHub Actions, scripts, and external services) during the release process. This makes the temporal flow and handoffs between systems more obvious. Moved the diagram under 'What Happens During Release' section where it makes more logical sense, as it explains the actual execution flow rather than being a standalone overview. * 📝 docs(release): remove custom theme for dark mode compatibility Custom theme colors looked poor on dark backgrounds. Using mermaid's default theme provides better contrast and readability across both light and dark modes. * 📝 docs(release): replace sequence with state diagram State diagram better represents the release flow as a progression through distinct states rather than message passing between actors. Shows version calculation logic, changelog generation, and publication steps as clear state transitions. * 📝 docs(release): make state diagram more compact Condensed the diagram by combining related steps and using shorter labels while maintaining clarity about the core flow. * 📝 docs(release): remove diagram, keep prose explanation The diagram became too simple after compression to provide meaningful value beyond what the prose already explains clearly. --------- Signed-off-by: Bernát Gábor <bgabor8@bloomberg.net>
1 parent eccc2c7 commit 5cc32a6

File tree

5 files changed

+157
-111
lines changed

5 files changed

+157
-111
lines changed

.github/workflows/bump-changelog.yml

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

.github/workflows/pre-release.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: Pre-release
2+
on:
3+
workflow_dispatch:
4+
inputs:
5+
bump:
6+
description: "Version bump type"
7+
required: true
8+
type: choice
9+
options:
10+
- auto
11+
- major
12+
- minor
13+
- patch
14+
default: auto
15+
16+
env:
17+
default-python: "3.13"
18+
19+
jobs:
20+
pre-release:
21+
runs-on: ubuntu-latest
22+
environment: release
23+
permissions:
24+
contents: write
25+
steps:
26+
- name: Checkout code
27+
uses: actions/checkout@v6
28+
with:
29+
fetch-depth: 0
30+
token: ${{ secrets.GH_RELEASE_TOKEN }}
31+
- name: Set up Python ${{ env.default-python }}
32+
uses: actions/setup-python@v6
33+
with:
34+
python-version: ${{ env.default-python }}
35+
- name: Install dependencies
36+
run: |
37+
python -m pip install --upgrade pip
38+
pip install GitPython packaging towncrier pre-commit
39+
- name: Configure git identity
40+
run: |
41+
git config user.name "github-actions[bot]"
42+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
43+
- name: Generate changelog, commit, tag, and push
44+
run: python scripts/release.py --version "${{ inputs.bump }}"

.github/workflows/release.yml

Lines changed: 12 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
11
name: Release
22

33
on:
4-
workflow_dispatch:
5-
inputs:
6-
version:
7-
description: "Version to release"
8-
required: true
9-
type: string
10-
pull_request_target:
11-
types:
12-
- closed
4+
push:
5+
tags:
6+
- "*.*.*"
7+
138
concurrency:
149
group: ${{ github.workflow }}-${{ github.ref }}
1510
cancel-in-progress: true
@@ -19,50 +14,19 @@ env:
1914
minimum-supported-python: "3.9"
2015

2116
jobs:
22-
create-tag:
23-
name: Create the Git tag
24-
if: >-
25-
github.event_name == 'workflow_dispatch' ||
26-
github.event.pull_request.merged == true
27-
&& contains(github.event.pull_request.labels.*.name, 'release-version')
28-
runs-on: ubuntu-latest
29-
outputs:
30-
release-tag: ${{ steps.get-version.outputs.version }}
31-
permissions:
32-
contents: write
33-
steps:
34-
- uses: actions/checkout@v6
35-
- name: Extract version to be released
36-
id: get-version
37-
env:
38-
PR_TITLE: ${{ github.event.pull_request.title }}
39-
run: |
40-
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
41-
echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT"
42-
else
43-
echo "version=${PR_TITLE/: [[:alnum:]]*}" >> "$GITHUB_OUTPUT"
44-
fi
45-
- name: Bump version and push tag
46-
uses: mathieudutour/github-tag-action@v6.2
47-
with:
48-
custom_tag: "${{ steps.get-version.outputs.version }}"
49-
github_token: ${{ secrets.GITHUB_TOKEN }}
50-
tag_prefix: ""
51-
5217
pypi-publish:
5318
name: Publish pipx to PyPI
54-
needs: create-tag
5519
runs-on: ubuntu-latest
5620
environment:
5721
name: release
5822
url: https://pypi.org/p/pipx
5923
permissions:
6024
id-token: write
6125
steps:
62-
- name: Checkout ${{ needs.create-tag.outputs.release-tag }}
26+
- name: Checkout ${{ github.ref_name }}
6327
uses: actions/checkout@v6
6428
with:
65-
ref: "${{ needs.create-tag.outputs.release-tag }}"
29+
ref: "${{ github.ref_name }}"
6630
- name: Set up Python ${{ env.default-python }}
6731
uses: actions/setup-python@v6
6832
with:
@@ -77,7 +41,7 @@ jobs:
7741

7842
create-release:
7943
name: Create a release on GitHub's UI
80-
needs: [pypi-publish, create-tag]
44+
needs: pypi-publish
8145
runs-on: ubuntu-latest
8246
permissions:
8347
contents: write
@@ -87,19 +51,19 @@ jobs:
8751
uses: softprops/action-gh-release@v2
8852
with:
8953
generate_release_notes: true
90-
tag_name: "${{ needs.create-tag.outputs.release-tag }}"
54+
tag_name: "${{ github.ref_name }}"
9155

9256
upload-zipapp:
9357
name: Upload zipapp to GitHub Release
94-
needs: [create-release, create-tag]
58+
needs: create-release
9559
runs-on: ubuntu-latest
9660
permissions:
9761
contents: write
9862
steps:
99-
- name: Checkout ${{ needs.create-tag.outputs.release-tag }}
63+
- name: Checkout ${{ github.ref_name }}
10064
uses: actions/checkout@v6
10165
with:
102-
ref: "${{ needs.create-tag.outputs.release-tag }}"
66+
ref: "${{ github.ref_name }}"
10367
- name: Set up Python ${{ env.minimum-supported-python }}
10468
uses: actions/setup-python@v6
10569
with:
@@ -113,4 +77,4 @@ jobs:
11377
uses: softprops/action-gh-release@v2
11478
with:
11579
files: pipx.pyz
116-
tag_name: "${{ needs.create-tag.outputs.release-tag }}"
80+
tag_name: "${{ github.ref_name }}"

docs/contributing.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -220,15 +220,18 @@ nox -s watch_docs
220220
221221
## Releasing New `pipx` Versions
222222
223-
To release a new version, manually run the `bump-changelog` action under the *"Actions"* tab, passing it the version to be released. This will create a pull request updating the changelog for the upcoming version, with the `release-version` label. Merging this PR will automatically trigger the release workflows.
223+
The release process for pipx is designed to be simple and fully automated with a single button press. The workflow automatically determines the next version based on changelog fragments, generates the changelog, creates the release commit, and publishes to PyPI.
224224
225-
Attaching this label to any pull request of which the title follows the format `<Version>: Description` and merging it will trigger the release workflows as well.
225+
### Initiating a Release
226226
227-
The release workflow consists of publishing:
227+
Navigate to the **Actions** tab in the GitHub repository and select the **Pre-release** workflow. Click **Run workflow** and choose the appropriate version bump strategy. The `auto` option intelligently determines whether a minor or patch bump is needed by examining the types of changelog fragments present. If new features or removals exist, it performs a minor version bump; otherwise, it increments the patch version. Alternatively, you can explicitly select `major`, `minor`, or `patch` to control the version increment directly.
228228
229-
- the pipx version to PyPI,
230-
- the documentation to ReadTheDocs,
231-
- a GitHub release,
232-
- the `zipapp` to the GitHub release created.
229+
### What Happens During Release
233230
234-
No need for any other pre or post publish steps.
231+
Once triggered, the pre-release workflow executes the `scripts/release.py` script which collects all changelog fragments from the `changelog.d/` directory and uses towncrier to generate the updated changelog. It then creates a release commit with the message "Release {version}" and tags it with the version number. After running pre-commit hooks to ensure formatting, both the commit and tag are pushed to the main branch.
232+
233+
The act of pushing a version tag (matching the pattern `*.*.*`) automatically triggers the main release workflow. This workflow builds the project distribution files, publishes the package to PyPI using trusted publishing, creates a GitHub release with auto-generated notes, and builds the zipapp using the minimum supported Python version before uploading it to the GitHub release assets.
234+
235+
### Version Calculation Examples
236+
237+
Starting from version `1.8.0`, the version bump types produce the following results: `auto` with feature fragments becomes `1.9.0`, while `auto` with only bugfixes becomes `1.8.1`. Selecting `major` explicitly jumps to `2.0.0`, `minor` moves to `1.9.0`, and `patch` increments to `1.8.1`. This automation eliminates the need for manual version management and ensures consistency across releases.

scripts/release.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Handles creating a release."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from subprocess import call, check_call
7+
8+
from git import Commit, Remote, Repo, TagReference # type: ignore[import-not-found]
9+
from packaging.version import Version
10+
11+
ROOT_DIR = Path(__file__).resolve().parents[1]
12+
CHANGELOG_DIR = ROOT_DIR / "changelog.d"
13+
14+
15+
def main(version_str: str, *, push: bool) -> None:
16+
repo = Repo(str(ROOT_DIR))
17+
if repo.is_dirty():
18+
msg = "Current repository is dirty. Please commit any changes and try again."
19+
raise RuntimeError(msg)
20+
remote = get_remote(repo)
21+
remote.fetch()
22+
version = resolve_version(version_str, repo)
23+
print(f"Releasing {version}")
24+
release_commit = release_changelog(repo, version)
25+
tag = tag_release_commit(release_commit, repo, version)
26+
if push:
27+
print("Pushing release commit")
28+
repo.git.push(remote.name, "HEAD:main")
29+
print("Pushing release tag")
30+
repo.git.push(remote.name, tag)
31+
print("All done! ✨ 🍰 ✨")
32+
33+
34+
def resolve_version(version_str: str, repo: Repo) -> Version:
35+
if version_str not in {"auto", "major", "minor", "patch"}:
36+
return Version(version_str)
37+
latest_tag = repo.git.describe("--tags", "--abbrev=0")
38+
parts = [int(x) for x in latest_tag.split(".")]
39+
if version_str == "major":
40+
parts = [parts[0] + 1, 0, 0]
41+
elif version_str == "minor":
42+
parts = [parts[0], parts[1] + 1, 0]
43+
elif version_str == "patch":
44+
parts[2] += 1
45+
elif any(CHANGELOG_DIR.glob("*.feature.md")) or any(CHANGELOG_DIR.glob("*.removal.md")):
46+
parts = [parts[0], parts[1] + 1, 0]
47+
else:
48+
parts[2] += 1
49+
return Version(".".join(str(p) for p in parts))
50+
51+
52+
def get_remote(repo: Repo) -> Remote:
53+
upstream_remote = "pypa/pipx"
54+
urls = set()
55+
for remote in repo.remotes:
56+
for url in remote.urls:
57+
if url.rstrip(".git").endswith(upstream_remote):
58+
return remote
59+
urls.add(url)
60+
msg = f"Could not find {upstream_remote} remote, has {urls}"
61+
raise RuntimeError(msg)
62+
63+
64+
def release_changelog(repo: Repo, version: Version) -> Commit:
65+
print("Generating release commit")
66+
check_call(["towncrier", "build", "--yes", "--version", version.public], cwd=str(ROOT_DIR))
67+
call(["pre-commit", "run", "--all-files"], cwd=str(ROOT_DIR))
68+
repo.git.add(".")
69+
check_call(["pre-commit", "run", "--all-files"], cwd=str(ROOT_DIR))
70+
return repo.index.commit(f"Release {version}")
71+
72+
73+
def tag_release_commit(release_commit: Commit, repo: Repo, version: Version) -> TagReference:
74+
print("Tagging release commit")
75+
existing_tags = [x.name for x in repo.tags]
76+
if str(version) in existing_tags:
77+
print(f"Deleting existing tag {version}")
78+
repo.delete_tag(str(version))
79+
print(f"Creating tag {version}")
80+
return repo.create_tag(str(version), ref=release_commit, force=True)
81+
82+
83+
if __name__ == "__main__":
84+
import argparse
85+
86+
parser = argparse.ArgumentParser(prog="release")
87+
parser.add_argument("--version", default="auto")
88+
parser.add_argument("--no-push", action="store_true")
89+
options = parser.parse_args()
90+
main(options.version, push=not options.no_push)

0 commit comments

Comments
 (0)