Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 116 additions & 13 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
@@ -1,49 +1,152 @@
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

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
74 changes: 74 additions & 0 deletions docs/development.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://pypi.org/project/virtualenv/>`_, creates a
`GitHub Release <https://github.com/pypa/virtualenv/releases>`_ with the zipapp attached, and updates
`get-virtualenv <https://github.com/pypa/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 <https://github.com/pypa/virtualenv/actions/workflows/release.yaml>`_ 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 <https://github.com/pypa/virtualenv/releases>`_ 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 <bump>`` 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 <version> 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 "<version>" > /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 <version>"
git -C /tmp/get-virtualenv push origin main

The push triggers ``get-virtualenv``'s own
`release workflow <https://github.com/pypa/get-virtualenv/blob/main/.github/workflows/release.yml>`_ which
automatically creates a tag and GitHub Release with the zipapp attached.

Contributing
-------------

Expand Down
63 changes: 37 additions & 26 deletions tasks/release.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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:
Expand All @@ -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)
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading