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