diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000..553244d66 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,76 @@ +name: Publish + +on: + workflow_dispatch: + inputs: + ref: + # require SHA as tags and branches are mutable + description: Git commit SHA (not branch/tag) + required: true + type: string + +env: + FORCE_COLOR: 1 + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Log inputs + run: echo "${{ inputs }}" + + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ inputs.ref }} + fetch-depth: 2 # shouldn't need much, if any, history + + - name: Log Git tag + run: git describe --always --tags + + - name: Check ref is the commit SHA + # require SHA as tags and branches are mutable + run: test "${{ inputs.ref }}" + = "$(git git log --max-count=1 --format=format:%h)" + || test "${{ inputs.ref }}" + = "$(git git log --max-count=1 --format=format:%H)" + + # pipx is pre-installed in 'ubuntu' VMs + + - name: Provision nox environment + run: pipx run nox --install-only + + - name: Build distribution via nox + run: pipx run nox --no-install --error-on-missing-interpreters -s release_build + + - name: Upload distribution + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + with: + name: packages + path: dist/ + if-no-files-found: error + compression-level: 0 + + publish: + needs: + - build + environment: + name: pypi + url: https://pypi.org/project/packaging/${{ github.ref_name }} + permissions: + id-token: write + + runs-on: ubuntu-latest + + steps: + - name: Download distribution + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # 4.3.0 + with: + name: packages + path: dist/ + + - name: Publish distribution to PyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + with: + print-hash: true diff --git a/docs/development/release-process.rst b/docs/development/release-process.rst index e4e40d256..5e9b52511 100644 --- a/docs/development/release-process.rst +++ b/docs/development/release-process.rst @@ -11,10 +11,14 @@ Release Process $ nox -s release -- YY.N - You will need the password for your GPG key as well as an API token for PyPI. + This creates and pushes a new tag for the release. #. Add a `release on GitHub `__. +#. Run the 'Publish' manual GitHub workflow, specifying the Git tag's commit + SHA. This will build and publish the package to PyPI. Publishing will wait + for any `required approvals`_. + #. Notify the other project owners of the release. .. note:: @@ -24,3 +28,5 @@ Release Process - PyPI maintainer (or owner) access to ``packaging`` - push directly to the ``main`` branch on the source repository - push tags directly to the source repository + +.. _required approvals: https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-deployments/reviewing-deployments#approving-or-rejecting-a-job diff --git a/noxfile.py b/noxfile.py index 04fe8fd5e..2a4932e1c 100644 --- a/noxfile.py +++ b/noxfile.py @@ -16,7 +16,6 @@ import tempfile import textwrap import time -import webbrowser from pathlib import Path from typing import IO, Generator @@ -129,6 +128,9 @@ def release(session: nox.Session) -> None: session.run("git", "add", str(changelog_file), external=True) _bump(session, version=release_version, file=version_file, kind="release") + # Check the built distribution. + _build_and_check(session, release_version, remove=True) + # Tag the release commit. # fmt: off session.run( @@ -147,11 +149,76 @@ def release(session: nox.Session) -> None: next_version = f"{major}.{minor + 1}.dev0" _bump(session, version=next_version, file=version_file, kind="development") - # Checkout the git tag. - session.run("git", "checkout", "-q", release_version, external=True) + # Push the commits and tag. + # NOTE: The following fails if pushing to the branch is not allowed. This can + # happen on GitHub, if the main branch is protected, there are required + # CI checks and "Include administrators" is enabled on the protection. + session.run("git", "push", "upstream", "main", release_version, external=True) + + +@nox.session +def release_build(session: nox.Session) -> None: + # Parse version from command-line arguments, if provided, otherwise get + # from Git tag. + release_version: str | None + try: + release_version = _get_version_from_arguments(session.posargs) + except ValueError as e: + if session.posargs: + session.error(f"Invalid arguments: {e}") + + release_version = session.run( + "git", "describe", "--exact-match", silent=True, external=True + ) + release_version = "" if release_version is None else release_version.strip() + session.debug(f"version: {release_version}") + checkout = False + else: + checkout = True + + # Check state of working directory. + _check_working_directory_state(session) + + # Ensure there are no uncommitted changes. + result = subprocess.run( + ["git", "status", "--porcelain"], + check=False, + capture_output=True, + encoding="utf-8", + ) + if result.stdout: + print(result.stdout, end="", file=sys.stderr) + session.error("The working tree has uncommitted changes") + + # Check out the Git tag, if provided. + if checkout: + session.run("git", "switch", "-q", release_version, external=True) + + # Build the distribution. + _build_and_check(session, release_version) + + # Get back out into main, if we checked out before. + if checkout: + session.run("git", "switch", "-q", "main", external=True) + + +def _build_and_check( + session: nox.Session, + release_version: str, + remove: bool = False, +) -> None: + package_name = "packaging" session.install("build", "twine") + # Determine if we're in install-only mode. This works as `python --version` + # should always succeed when running `nox`, but in install-only mode + # `session.run(..., silent=True)` always immediately returns `None` instead + # of invoking the command and returning the command's output. See the + # documentation at: + # https://nox.thea.codes/en/stable/usage.html#skipping-everything-but-install-commands + install_only = session.run("python", "--version", silent=True) is None + # Build the distribution. session.run("python", "-m", "build") @@ -161,30 +228,19 @@ def release(session: nox.Session) -> None: f"dist/{package_name}-{release_version}-py3-none-any.whl", f"dist/{package_name}-{release_version}.tar.gz", ] - if files != expected: + if files != expected and not install_only: diff_generator = difflib.context_diff( expected, files, fromfile="expected", tofile="got", lineterm="" ) diff = "\n".join(diff_generator) session.error(f"Got the wrong files:\n{diff}") - # Get back out into main. - session.run("git", "checkout", "-q", "main", external=True) - - # Check and upload distribution files. - session.run("twine", "check", *files) - - # Push the commits and tag. - # NOTE: The following fails if pushing to the branch is not allowed. This can - # happen on GitHub, if the main branch is protected, there are required - # CI checks and "Include administrators" is enabled on the protection. - session.run("git", "push", "upstream", "main", release_version, external=True) - - # Upload the distribution. - session.run("twine", "upload", *files) + # Check distribution files. + session.run("twine", "check", "--strict", *files) - # Open up the GitHub release page. - webbrowser.open("https://github.com/pypa/packaging/releases") + # Remove distribution files, if requested. + if remove and not install_only: + shutil.rmtree("dist", ignore_errors=True) @nox.session(default=False)