Skip to content

Commit

Permalink
Automated releases - Round 1 (#265)
Browse files Browse the repository at this point in the history
First shot at automating the release process
  • Loading branch information
pradyunsg authored Jan 28, 2020
1 parent 54d57d7 commit 939b28b
Show file tree
Hide file tree
Showing 3 changed files with 171 additions and 26 deletions.
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ repos:
args: []
- id: mypy
name: mypy for Python 2
exclude: '^(docs|tasks|tests)|setup\.py'
exclude: '^(docs|tasks|tests)|setup\.py|noxfile\.py'
args: ['--py2']

- repo: https://github.com/psf/black
Expand Down
39 changes: 15 additions & 24 deletions docs/development/release-process.rst
Original file line number Diff line number Diff line change
@@ -1,35 +1,26 @@
Release Process
===============

#. Checkout the current ``master`` branch, with a clean working directory.
#. Modify the ``CHANGELOG.rst`` to include changes made since the last release
and update the section header for the new release.
#. Bump the version in ``packaging/__about__.py``

#. Install the latest ``setuptools``, ``wheel`` and ``twine`` packages
from PyPI::

$ pip install --upgrade setuptools wheel twine

#. Ensure no ``dist/`` folder exists and then create the distribution files::
#. Checkout the current ``master`` branch.
#. Install the latest ``nox``::

$ python setup.py sdist bdist_wheel
$ pip install nox

#. Check the built distribution files with ``twine``::

$ twine check dist/*
#. Modify the ``CHANGELOG.rst`` to include changes made since the last release
and update the section header for the new release.

#. Commit the changes to ``master``.
#. Run the release automation with the required version number (YY.N)::

#. If all goes well, upload the build distribution files::
$ nox -s release -- YY.N

$ twine upload dist/*
#. Modify the ``CHANGELOG.rst`` to reflect the development version does not
have any changes since the last release.

#. Create a
`release on GitHub <https://github.com/pypa/packaging/releases>`_ and
include the artifacts uploaded to PyPI.
#. Notify the other project owners of the release.

#. Bump the version for development in ``packaging/__about__.py`` and
``CHANGELOG.rst``.
.. note::
Access needed for making the release are:

#. Notify the other project owners of the release.
- PyPI maintainer (or owner) access to `packaging`
- push directly to the `master` branch on the source repository
- push tags directly to the source repository
156 changes: 155 additions & 1 deletion noxfile.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
# mypy: disallow-untyped-defs=False, disallow-untyped-calls=False

import time
import re
import os
import sys
import glob
import shutil
import subprocess
from pathlib import Path

import nox

Expand Down Expand Up @@ -31,7 +37,7 @@ def lint(session):
session.run("pre-commit", "run", "--all-files")

# Check the distribution
session.install("setuptools", "readme_renderer", "twine", "wheel")
session.install("setuptools", "twine", "wheel")
session.run("python", "setup.py", "--quiet", "sdist", "bdist_wheel")
session.run("twine", "check", *glob.glob("dist/*"))

Expand Down Expand Up @@ -59,3 +65,151 @@ def docs(session):
"docs", # source directory
"docs/_build/" + dest, # output directory
)


@nox.session
def release(session):
package_name = "packaging"
version_file = Path(f"{package_name}/__about__.py")

try:
release_version = _get_version_from_arguments(session.posargs)
except ValueError as e:
session.error(f"Invalid arguments: {e}")

# Check state of working directory and git.
_check_working_directory_state(session)
_check_git_state(session, release_version)

# Update to the release version.
_bump(session, version=release_version, file=version_file, kind="release")

# Tag the release commit.
session.run("git", "tag", "-s", release_version, external=True)

# Bump the version for development.
major, minor = map(int, release_version.split("."))
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)

# Build the distribution.
session.run("python", "setup.py", "sdist", "bdist_wheel")

# Check what files are in dist/ for upload.
files = glob.glob(f"dist/*")
assert sorted(files) == [
f"dist/{package_name}-{release_version}.tag.gz",
f"dist/{package_name}-{release_version}-py2.py3-none-any.whl",
], f"Got the wrong files: {files}"

# Get back out into master.
session.run("git", "checkout", "-q", "master", external=True)

# Check and upload distribution files.
session.run("twine", "check", *files)

# Upload the distribution.
session.run("twine", "upload", *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 master branch is protected, there are required
# CI checks and "Include administrators" is enabled on the protection.
session.run("git", "push", "upstream", "master", release_version, external=True)


# -----------------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------------
def _get_version_from_arguments(arguments):
"""Checks the arguments passed to `nox -s release`.
Only 1 argument that looks like a version? Return the argument.
Otherwise, raise a ValueError describing what's wrong.
"""
if len(arguments) != 1:
raise ValueError("Expected exactly 1 argument")

version = arguments[0]
parts = version.split(".")

if len(parts) != 2:
# Not of the form: YY.N
raise ValueError("not of the form: YY.N")

if not all(part.isdigit() for part in parts):
# Not all segments are integers.
raise ValueError("non-integer segments")

# All is good.
return version


def _check_working_directory_state(session):
"""Check state of the working directory, prior to making the release.
"""
should_not_exist = ["build/", "dist/"]

bad_existing_paths = list(filter(os.path.exists, should_not_exist))
if bad_existing_paths:
session.error(f"Remove {', '.join(bad_existing_paths)} and try again")


def _check_git_state(session, version_tag):
"""Check state of the git repository, prior to making the release.
"""
# Ensure the upstream remote pushes to the correct URL.
allowed_upstreams = [
"git@github.com:pypa/packaging.git",
"https://github.com/pypa/packaging.git",
]
result = subprocess.run(
["git", "remote", "get-url", "--push", "upstream"],
capture_output=True,
encoding="utf-8",
)
if result.stdout.rstrip() not in allowed_upstreams:
session.error(f"git remote `upstream` is not one of {allowed_upstreams}")
# Ensure we're on master branch for cutting a release.
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
encoding="utf-8",
)
if result.stdout != "master\n":
session.error(f"Not on master branch: {result.stdout!r}")

# Ensure there are no uncommitted changes.
result = subprocess.run(
["git", "status", "--porcelain"], capture_output=True, encoding="utf-8"
)
if result.stdout:
print(result.stdout, end="", file=sys.stderr)
session.error(f"The working tree has uncommitted changes")

# Ensure this tag doesn't exist already.
result = subprocess.run(
["git", "rev-parse", version_tag], capture_output=True, encoding="utf-8"
)
if not result.returncode:
session.error(f"Tag already exists! {version_tag} -- {result.stdout!r}")

# Back up the current git reference, in a tag that's easy to clean up.
_release_backup_tag = "auto/release-start-" + str(int(time.time()))
session.run("git", "tag", _release_backup_tag, external=True)


def _bump(session, *, version, file, kind):
session.log(f"Bump version to {version!r}")
contents = file.read_text()
new_contents = re.sub(
'__version__ = "(.+)"', f'__version__ = "{version}"', contents
)
file.write_text(new_contents)

session.log(f"git commit")
subprocess.run(["git", "add", str(file)])
subprocess.run(["git", "commit", "-m", f"Bump for {kind}"])

0 comments on commit 939b28b

Please sign in to comment.