diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index def30f5d9..5d9f6a4e5 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -1,7 +1,17 @@
-name: Release to PyPI
+name: Release
on:
- push:
- tags: ["*"]
+ workflow_dispatch:
+ inputs:
+ bump:
+ description: "Version bump type"
+ required: true
+ type: choice
+ options:
+ - auto
+ - major
+ - minor
+ - patch
+ default: auto
env:
dists-artifact-name: python-package-distributions
@@ -9,41 +19,134 @@ env:
jobs:
build:
runs-on: ubuntu-24.04
+ outputs:
+ version: ${{ steps.resolve.outputs.version }}
steps:
- - name: 📥 Checkout code
+ - name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- - name: 🚀 Install the latest version of uv
+ token: ${{ secrets.GH_RELEASE_TOKEN }}
+ - name: Install the latest version of uv
uses: astral-sh/setup-uv@v4
with:
enable-cache: true
cache-dependency-glob: "pyproject.toml"
github-token: ${{ secrets.GITHUB_TOKEN }}
- - name: 📦 Build package
+ - name: Set up Python
+ run: uv python install 3.14
+ - name: Configure git identity from token owner
+ env:
+ GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
+ run: |
+ user_info=$(gh api /user)
+ git config user.name "$(echo "$user_info" | jq -r '.name // .login')"
+ git config user.email "$(echo "$user_info" | jq -r '.id')+$(echo "$user_info" | jq -r '.login')@users.noreply.github.com"
+ - name: Generate changelog, commit, and tag locally
+ id: resolve
+ run: |
+ uv tool run --with tox-uv tox r -e release -- --version "${{ inputs.bump }}" --no-push
+ echo "version=$(git describe --tags --abbrev=0)" >> "$GITHUB_OUTPUT"
+ - name: Build sdist and wheel
run: uv build --python 3.14 --python-preference only-managed --sdist --wheel . --out-dir dist
- - name: 📦 Store the distribution packages
+ - name: Build zipapp
+ run: uv tool run --with packaging --with pip tox r -e zipapp
+ - name: Store the distribution packages
uses: actions/upload-artifact@v4
with:
name: ${{ env.dists-artifact-name }}
path: dist/*
+ - name: Store the zipapp
+ uses: actions/upload-artifact@v4
+ with:
+ name: virtualenv-zipapp
+ path: virtualenv.pyz
- release:
- needs:
- - build
+ publish:
+ needs: build
runs-on: ubuntu-24.04
environment:
name: release
- url: https://pypi.org/project/virtualenv/${{ github.ref_name }}
+ url: https://pypi.org/project/virtualenv/${{ needs.build.outputs.version }}
permissions:
+ contents: write
id-token: write
steps:
- - name: 📥 Download all the dists
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.GH_RELEASE_TOKEN }}
+ - name: Push release commit and tag
+ run: |
+ git push origin HEAD:main
+ git push origin "${{ needs.build.outputs.version }}"
+ - name: Download all the dists
uses: actions/download-artifact@v4
with:
name: ${{ env.dists-artifact-name }}
path: dist/
- - name: 🚀 Publish to PyPI
+ - name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@v1.13.0
with:
attestations: true
+ - name: Download the zipapp
+ uses: actions/download-artifact@v4
+ with:
+ name: virtualenv-zipapp
+ - name: Create GitHub Release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ needs.build.outputs.version }}
+ generate_release_notes: true
+ files: virtualenv.pyz
+ - name: Update get-virtualenv
+ env:
+ GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
+ run: |
+ git clone https://x-access-token:${GH_TOKEN}@github.com/pypa/get-virtualenv.git
+ cp virtualenv.pyz get-virtualenv/public/virtualenv.pyz
+ echo -n "${{ needs.build.outputs.version }}" > get-virtualenv/public/version.txt
+ cd get-virtualenv
+ user_info=$(gh api /user)
+ git config user.name "$(echo "$user_info" | jq -r '.name // .login')"
+ git config user.email "$(echo "$user_info" | jq -r '.id')+$(echo "$user_info" | jq -r '.login')@users.noreply.github.com"
+ git add public/virtualenv.pyz public/version.txt
+ git commit -m "update virtualenv to ${{ needs.build.outputs.version }}"
+ git push origin main
+
+ rollback:
+ if: ${{ always() && needs.build.result == 'success' && needs.publish.result == 'failure' }}
+ needs:
+ - build
+ - publish
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.GH_RELEASE_TOKEN }}
+ - name: Delete GitHub Release if created
+ env:
+ GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
+ run: gh release delete "${{ needs.build.outputs.version }}" --yes --cleanup-tag || true
+ - name: Delete remote tag
+ run: git push origin --delete "${{ needs.build.outputs.version }}" || true
+ - name: Reset release commit on main
+ run: |
+ git checkout main
+ git reset --hard HEAD~1
+ git push origin main --force
+ - name: Rollback get-virtualenv if updated
+ env:
+ GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
+ run: |
+ git clone https://x-access-token:${GH_TOKEN}@github.com/pypa/get-virtualenv.git
+ cd get-virtualenv
+ current_version=$(cat public/version.txt)
+ if [ "$current_version" = "${{ needs.build.outputs.version }}" ]; then
+ gh release delete "$current_version" --yes --cleanup-tag || true
+ git reset --hard HEAD~1
+ git push origin main --force
+ fi
diff --git a/docs/development.rst b/docs/development.rst
index 868974179..5d110f2ff 100644
--- a/docs/development.rst
+++ b/docs/development.rst
@@ -110,6 +110,80 @@ virtualenv's release schedule is tied to ``pip`` and ``setuptools``. We bundle t
libraries so each time there's a new version of any of these, there will be a new virtualenv release shortly afterwards
(we usually wait just a few days to avoid pulling in any broken releases).
+Performing a release
+^^^^^^^^^^^^^^^^^^^^
+
+A full release publishes to `PyPI `_, creates a
+`GitHub Release `_ with the zipapp attached, and updates
+`get-virtualenv `_ so that ``https://bootstrap.pypa.io/virtualenv.pyz`` serves
+the new version.
+
+Version bumping
+"""""""""""""""
+
+The ``--version`` argument to ``tox r -e release`` controls the version. It defaults to ``auto``, which inspects the
+``docs/changelog`` directory: if any ``*.feature.rst`` or ``*.removal.rst`` fragments exist, the minor version is bumped,
+otherwise the patch version is bumped. You can also pass ``major``, ``minor``, or ``patch`` explicitly.
+
+**Via GitHub Actions (recommended)**
+
+#. Go to the `Release workflow `_ on GitHub.
+#. Click **Run workflow** and select the bump type (``auto``, ``major``, ``minor``, or ``patch``).
+#. The workflow runs in two phases:
+
+ **Build** (nothing is published yet):
+
+ - Generates the changelog from ``docs/changelog`` fragments via :pypi:`towncrier`.
+ - Creates the release commit and tag locally.
+ - Builds the sdist, wheel, and zipapp (``virtualenv.pyz``).
+
+ **Publish** (only if build succeeds):
+
+ - Pushes the release commit and tag to ``main``.
+ - Publishes the sdist and wheel to PyPI.
+ - Creates a `GitHub Release `_ with the zipapp attached.
+ - Pushes the new zipapp and version to ``get-virtualenv``.
+
+ If publish fails, a **rollback** job automatically reverts the release commit, deletes the tag and GitHub Release on
+ both ``virtualenv`` and ``get-virtualenv``.
+
+**Locally**
+
+#. Generate the changelog, create the release commit, tag, and push:
+
+ .. code-block:: console
+
+ tox r -e release
+
+ Pass ``--version `` to override the default ``auto`` behavior (e.g. ``--version minor``).
+
+#. Build the zipapp:
+
+ .. code-block:: console
+
+ tox r -e zipapp
+
+#. Create a GitHub Release and attach the zipapp:
+
+ .. code-block:: console
+
+ gh release create virtualenv.pyz --generate-notes
+
+#. Update ``get-virtualenv`` with the new zipapp:
+
+ .. code-block:: console
+
+ git clone https://github.com/pypa/get-virtualenv.git /tmp/get-virtualenv
+ cp virtualenv.pyz /tmp/get-virtualenv/public/virtualenv.pyz
+ echo -n "" > /tmp/get-virtualenv/public/version.txt
+ git -C /tmp/get-virtualenv add public/virtualenv.pyz public/version.txt
+ git -C /tmp/get-virtualenv commit -m "update virtualenv to "
+ git -C /tmp/get-virtualenv push origin main
+
+ The push triggers ``get-virtualenv``'s own
+ `release workflow `_ which
+ automatically creates a tag and GitHub Release with the zipapp attached.
+
Contributing
-------------
diff --git a/tasks/release.py b/tasks/release.py
index feda8b3c2..1b7bee141 100644
--- a/tasks/release.py
+++ b/tasks/release.py
@@ -1,46 +1,55 @@
-"""Handles creating a release PR."""
+"""Handles creating a release."""
from __future__ import annotations
from pathlib import Path
from subprocess import check_call
-from git import Commit, Head, Remote, Repo, TagReference
+from git import Commit, Remote, Repo, TagReference
from packaging.version import Version
ROOT_SRC_DIR = Path(__file__).resolve().parents[1]
+CHANGELOG_DIR = ROOT_SRC_DIR / "docs" / "changelog"
-def main(version_str: str) -> None:
- version = Version(version_str)
+def main(version_str: str, *, push: bool) -> None:
repo = Repo(str(ROOT_SRC_DIR))
-
if repo.is_dirty():
msg = "Current repository is dirty. Please commit any changes and try again."
raise RuntimeError(msg)
- upstream, release_branch = create_release_branch(repo, version)
+ remote = get_remote(repo)
+ remote.fetch()
+ version = resolve_version(version_str, repo)
+ print(f"releasing {version}") # noqa: T201
release_commit = release_changelog(repo, version)
tag = tag_release_commit(release_commit, repo, version)
- print("push release commit") # noqa: T201
- repo.git.push(upstream.name, release_branch)
- print("push release tag") # noqa: T201
- repo.git.push(upstream.name, tag)
+ if push:
+ print("push release commit") # noqa: T201
+ repo.git.push(remote.name, "HEAD:main")
+ print("push release tag") # noqa: T201
+ repo.git.push(remote.name, tag)
print("All done! ✨ 🍰 ✨") # noqa: T201
-def create_release_branch(repo: Repo, version: Version) -> tuple[Remote, Head]:
- print("create release branch from upstream main") # noqa: T201
- upstream = get_upstream(repo)
- upstream.fetch()
- branch_name = f"release-{version}"
- release_branch = repo.create_head(branch_name, upstream.refs.main, force=True)
- upstream.push(refspec=f"{branch_name}:{branch_name}", force=True)
- release_branch.set_tracking_branch(repo.refs[f"{upstream.name}/{branch_name}"])
- release_branch.checkout()
- return upstream, release_branch
-
-
-def get_upstream(repo: Repo) -> Remote:
+def resolve_version(version_str: str, repo: Repo) -> Version:
+ if version_str not in {"auto", "major", "minor", "patch"}:
+ return Version(version_str)
+ latest_tag = repo.git.describe("--tags", "--abbrev=0")
+ parts = [int(x) for x in latest_tag.split(".")]
+ if version_str == "major":
+ parts = [parts[0] + 1, 0, 0]
+ elif version_str == "minor":
+ parts = [parts[0], parts[1] + 1, 0]
+ elif version_str == "patch":
+ parts[2] += 1
+ elif any(CHANGELOG_DIR.glob("*.feature.rst")) or any(CHANGELOG_DIR.glob("*.removal.rst")):
+ parts = [parts[0], parts[1] + 1, 0]
+ else:
+ parts[2] += 1
+ return Version(".".join(str(p) for p in parts))
+
+
+def get_remote(repo: Repo) -> Remote:
upstream_remote = "pypa/virtualenv.git"
urls = set()
for remote in repo.remotes:
@@ -55,10 +64,11 @@ def get_upstream(repo: Repo) -> Remote:
def release_changelog(repo: Repo, version: Version) -> Commit:
print("generate release commit") # noqa: T201
check_call(["towncrier", "build", "--yes", "--version", version.public], cwd=str(ROOT_SRC_DIR)) # noqa: S607
+ repo.git.add(".")
return repo.index.commit(f"release {version}")
-def tag_release_commit(release_commit, repo, version) -> TagReference:
+def tag_release_commit(release_commit: Commit, repo: Repo, version: Version) -> TagReference:
print("tag release commit") # noqa: T201
existing_tags = [x.name for x in repo.tags]
if version in existing_tags:
@@ -72,6 +82,7 @@ def tag_release_commit(release_commit, repo, version) -> TagReference:
import argparse
parser = argparse.ArgumentParser(prog="release")
- parser.add_argument("--version", required=True)
+ parser.add_argument("--version", default="auto")
+ parser.add_argument("--no-push", action="store_true")
options = parser.parse_args()
- main(options.version)
+ main(options.version, push=not options.no_push)
diff --git a/tox.ini b/tox.ini
index 85d2abc62..141111f8e 100644
--- a/tox.ini
+++ b/tox.ini
@@ -106,7 +106,7 @@ deps =
towncrier>=24.8
change_dir = {toxinidir}/tasks
commands =
- python release.py --version {posargs}
+ python release.py {posargs}
[testenv:dev]
description = generate a DEV environment