improve release workflow

This commit is contained in:
Thomas Kriechbaumer 2018-05-17 11:25:32 +02:00
parent 762aa287cc
commit 2bbfcfae92
4 changed files with 401 additions and 83 deletions

View File

@ -1,12 +1,12 @@
# Release Checklist # Release Checklist
Make sure run all these steps on the correct branch you want to create a new Make sure to run all these steps on the correct branch you want to create a new
release for! The command examples assume that you have a git remote called release for! The command examples assume that you have a git remote called
`upstream` that points to the `mitmproxy/mitmproxy` repo. `upstream` that points to the `mitmproxy/mitmproxy` repo.
- Verify that `mitmproxy/version.py` is correct - Verify that `mitmproxy/version.py` is correct
- Update CHANGELOG - Update CHANGELOG
- Update CONTRIBUTORS - Update CONTRIBUTORS: `git shortlog -n -s > CONTRIBUTORS`
- Verify that all CI tests pass - Verify that all CI tests pass
- Create a major version branch - e.g. `v4.x`. Assuming you have a remote repo called `upstream` that points to the mitmproxy/mitmproxy repo:: - Create a major version branch - e.g. `v4.x`. Assuming you have a remote repo called `upstream` that points to the mitmproxy/mitmproxy repo::
- `git checkout -b v4.x upstream/master` - `git checkout -b v4.x upstream/master`
@ -20,16 +20,20 @@ release for! The command examples assume that you have a git remote called
- Wait for tag CI to complete - Wait for tag CI to complete
## GitHub Release ## GitHub Release
- Create release notice on Github [here](https://github.com/mitmproxy/mitmproxy/releases/new) if not already auto-created by the tag. - Create release notice on Github
- We DO NOT upload release artifacts to GitHub anymore. Simply add the following snippet to the notice: [here](https://github.com/mitmproxy/mitmproxy/releases/new) if not already
auto-created by the tag.
- We DO NOT upload release artifacts to GitHub anymore. Simply add the
following snippet to the notice:
`You can find the latest release packages on our snapshot server: https://snapshots.mitmproxy.org/v<version number here>` `You can find the latest release packages on our snapshot server: https://snapshots.mitmproxy.org/v<version number here>`
## PyPi ## PyPi
- Upload the whl file you downloaded in the prevous step - The created wheel is uploaded to PyPi automatically
- `twine upload ./tmp/snap/mitmproxy-4.0.0-py3-none-any.whl` - Please check https://pypi.python.org/pypi/mitmproxy about the latest version
## Homebrew ## Homebrew
- The Homebrew maintainers are typically very fast and detect our new relese within a day. - The Homebrew maintainers are typically very fast and detect our new relese
within a day.
- If you feel the need, you can run this from a macOS machine: - If you feel the need, you can run this from a macOS machine:
`brew bump-formula-pr --url https://github.com/mitmproxy/mitmproxy/archive/v<version number here>` `brew bump-formula-pr --url https://github.com/mitmproxy/mitmproxy/archive/v<version number here>`
@ -52,9 +56,10 @@ release for! The command examples assume that you have a git remote called
- Check the build details page again - Check the build details page again
## Website ## Website
- Update version here: https://github.com/mitmproxy/www/blob/master/src/config.toml - Update version here:
- `./build && ./upload-test` https://github.com/mitmproxy/www/blob/master/src/config.toml
- If everything looks alright: `./upload-prod` - Run `./build && ./upload-test`
- If everything looks alright, run `./upload-prod`
## Docs ## Docs
- Make sure you've uploaded the previous version's docs to archive - Make sure you've uploaded the previous version's docs to archive
@ -64,4 +69,5 @@ release for! The command examples assume that you have a git remote called
## Prepare for next release ## Prepare for next release
- Last but not least, bump the version on master in [https://github.com/mitmproxy/mitmproxy/blob/master/mitmproxy/version.py](mitmproxy/version.py) for major releases. - Last but not least, bump the version on master in
[https://github.com/mitmproxy/mitmproxy/blob/master/mitmproxy/version.py](mitmproxy/version.py) for major releases.

View File

@ -1,5 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import glob
import re
import contextlib import contextlib
import os import os
import platform import platform
@ -73,17 +75,17 @@ TOOLS = [
TAG = os.environ.get("TRAVIS_TAG", os.environ.get("APPVEYOR_REPO_TAG_NAME", None)) TAG = os.environ.get("TRAVIS_TAG", os.environ.get("APPVEYOR_REPO_TAG_NAME", None))
BRANCH = os.environ.get("TRAVIS_BRANCH", os.environ.get("APPVEYOR_REPO_BRANCH", None)) BRANCH = os.environ.get("TRAVIS_BRANCH", os.environ.get("APPVEYOR_REPO_BRANCH", None))
if TAG: if TAG:
VERSION = TAG VERSION = re.sub('^v', '', TAG)
UPLOAD_DIR = VERSION UPLOAD_DIR = VERSION
elif BRANCH: elif BRANCH:
VERSION = BRANCH VERSION = re.sub('^v', '', BRANCH)
UPLOAD_DIR = "branches/%s" % VERSION UPLOAD_DIR = "branches/%s" % VERSION
else: else:
print("Could not establish build name - exiting." % BRANCH) print("Could not establish build name - exiting." % BRANCH)
sys.exit(0) sys.exit(0)
print("BUILD VERSION=%s" % VERSION) print("BUILD VERSION=%s" % VERSION)
print("BUILD UPLOAD_DIR=%s" % UPLOAD_DIR)
def archive_name(bdist: str) -> str: def archive_name(bdist: str) -> str:
@ -99,23 +101,6 @@ def archive_name(bdist: str) -> str:
) )
def wheel_name() -> str:
return "mitmproxy-{version}-py3-none-any.whl".format(version=VERSION)
def installer_name() -> str:
ext = {
"Windows": "exe",
"Darwin": "dmg",
"Linux": "run"
}[platform.system()]
return "mitmproxy-{version}-{platform}-installer.{ext}".format(
version=VERSION,
platform=PLATFORM_TAG,
ext=ext,
)
@contextlib.contextmanager @contextlib.contextmanager
def chdir(path: str): def chdir(path: str):
old_dir = os.getcwd() old_dir = os.getcwd()
@ -134,7 +119,7 @@ def cli():
@cli.command("info") @cli.command("info")
def info(): def info():
print("Version: %s" % VERSION) click.echo("Version: %s" % VERSION)
@cli.command("build") @cli.command("build")
@ -142,23 +127,41 @@ def build():
""" """
Build a binary distribution Build a binary distribution
""" """
os.makedirs(DIST_DIR, exist_ok=True)
if "WHEEL" in os.environ:
build_wheel()
else:
click.echo("Not building wheels.")
build_pyinstaller()
def build_wheel():
click.echo("Building wheel...")
subprocess.check_call([
"python",
"setup.py",
"-q",
"bdist_wheel",
"--dist-dir", DIST_DIR,
])
whl = glob.glob(join(DIST_DIR, 'mitmproxy-*-py3-none-any.whl'))[0]
click.echo("Found wheel package: {}".format(whl))
subprocess.check_call([
"tox",
"-e", "wheeltest",
"--",
whl
])
def build_pyinstaller():
if exists(PYINSTALLER_TEMP): if exists(PYINSTALLER_TEMP):
shutil.rmtree(PYINSTALLER_TEMP) shutil.rmtree(PYINSTALLER_TEMP)
if exists(PYINSTALLER_DIST): if exists(PYINSTALLER_DIST):
shutil.rmtree(PYINSTALLER_DIST) shutil.rmtree(PYINSTALLER_DIST)
os.makedirs(DIST_DIR, exist_ok=True)
if "WHEEL" in os.environ:
print("Building wheel...")
subprocess.check_call(
[
"python",
"setup.py", "-q", "bdist_wheel",
"--dist-dir", "release/dist",
]
)
for bdist, tools in sorted(BDISTS.items()): for bdist, tools in sorted(BDISTS.items()):
with Archive(join(DIST_DIR, archive_name(bdist))) as archive: with Archive(join(DIST_DIR, archive_name(bdist))) as archive:
for tool in tools: for tool in tools:
@ -168,7 +171,7 @@ def build():
# This is PyInstaller, so it messes up paths. # This is PyInstaller, so it messes up paths.
# We need to make sure that we are in the spec folder. # We need to make sure that we are in the spec folder.
with chdir(PYINSTALLER_SPEC): with chdir(PYINSTALLER_SPEC):
print("Building %s binary..." % tool) click.echo("Building %s binary..." % tool)
excludes = [] excludes = []
if tool != "mitmweb": if tool != "mitmweb":
excludes.append("mitmproxy.tools.web") excludes.append("mitmproxy.tools.web")
@ -209,11 +212,11 @@ def build():
) )
executable = executable.replace("_main", "") executable = executable.replace("_main", "")
print("> %s --version" % executable) click.echo("> %s --version" % executable)
print(subprocess.check_output([executable, "--version"]).decode()) click.echo(subprocess.check_output([executable, "--version"]).decode())
archive.add(executable, basename(executable)) archive.add(executable, basename(executable))
print("Packed {}.".format(archive_name(bdist))) click.echo("Packed {}.".format(archive_name(bdist)))
def is_pr(): def is_pr():
@ -229,25 +232,40 @@ def is_pr():
@cli.command("upload") @cli.command("upload")
def upload(): def upload():
""" """
Upload snapshot to snapshot server Upload build artifacts to snapshot server and
upload wheel package to PyPi
""" """
# This requires some explanation. The AWS access keys are only exposed to # This requires some explanation. The AWS access keys are only exposed to
# privileged builds - that is, they are not available to PRs from forks. # privileged builds - that is, they are not available to PRs from forks.
# However, they ARE exposed to PRs from a branch within the main repo. This # However, they ARE exposed to PRs from a branch within the main repo. This
# check catches that corner case, and prevents an inadvertent upload. # check catches that corner case, and prevents an inadvertent upload.
if is_pr(): if is_pr():
print("Refusing to upload a pull request") click.echo("Refusing to upload a pull request")
return return
if "AWS_ACCESS_KEY_ID" in os.environ: if "AWS_ACCESS_KEY_ID" in os.environ:
subprocess.check_call( subprocess.check_call([
[ "aws", "s3", "cp",
"aws", "s3", "cp", "--acl", "public-read",
"--acl", "public-read", DIST_DIR + "/",
DIST_DIR + "/", "s3://snapshots.mitmproxy.org/%s/" % UPLOAD_DIR,
"s3://snapshots.mitmproxy.org/%s/" % UPLOAD_DIR, "--recursive",
"--recursive", ])
]
) upload_pypi = (
TAG and
"WHEEL" in os.environ and
"TWINE_USERNAME" in os.environ and
"TWINE_PASSWORD" in os.environ
)
if upload_pypi:
filename = "mitmproxy-{version}-py3-none-any.whl".format(version=VERSION)
click.echo("Uploading {} to PyPi...".format(filename))
subprocess.check_call([
"twine",
"upload",
join(DIST_DIR, filename)
])
@cli.command("decrypt") @cli.command("decrypt")

View File

@ -1,22 +1,78 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import contextlib import contextlib
import fnmatch
import os import os
import sys
import platform import platform
import re
import runpy import runpy
import shlex import shlex
import shutil
import subprocess import subprocess
from os.path import join, abspath, dirname import tarfile
import zipfile
from os.path import join, abspath, dirname, exists, basename
import cryptography.fernet
import click import click
import cryptography.fernet
import pysftp
# https://virtualenv.pypa.io/en/latest/userguide.html#windows-notes
# scripts and executables on Windows go in ENV\Scripts\ instead of ENV/bin/
if platform.system() == "Windows":
VENV_BIN = "Scripts"
PYINSTALLER_ARGS = [
# PyInstaller < 3.2 does not handle Python 3.5's ucrt correctly.
"-p", r"C:\Program Files (x86)\Windows Kits\10\Redist\ucrt\DLLs\x86",
]
else:
VENV_BIN = "bin"
PYINSTALLER_ARGS = []
# ZipFile and tarfile have slightly different APIs. Fix that.
if platform.system() == "Windows":
def Archive(name):
a = zipfile.ZipFile(name, "w")
a.add = a.write
return a
else:
def Archive(name):
return tarfile.open(name, "w:gz")
PLATFORM_TAG = {
"Darwin": "osx",
"Windows": "windows",
"Linux": "linux",
}.get(platform.system(), platform.system())
ROOT_DIR = abspath(join(dirname(__file__), "..")) ROOT_DIR = abspath(join(dirname(__file__), ".."))
RELEASE_DIR = join(ROOT_DIR, "release") RELEASE_DIR = join(ROOT_DIR, "release")
BUILD_DIR = join(RELEASE_DIR, "build")
DIST_DIR = join(RELEASE_DIR, "dist") DIST_DIR = join(RELEASE_DIR, "dist")
PYINSTALLER_SPEC = join(RELEASE_DIR, "specs")
# PyInstaller 3.2 does not bundle pydivert's Windivert binaries
PYINSTALLER_HOOKS = join(RELEASE_DIR, "hooks")
PYINSTALLER_TEMP = join(BUILD_DIR, "pyinstaller")
PYINSTALLER_DIST = join(BUILD_DIR, "binaries", PLATFORM_TAG)
VENV_DIR = join(BUILD_DIR, "venv")
# Project Configuration
VERSION_FILE = join(ROOT_DIR, "mitmproxy", "version.py") VERSION_FILE = join(ROOT_DIR, "mitmproxy", "version.py")
BDISTS = {
"mitmproxy": ["mitmproxy", "mitmdump", "mitmweb"],
"pathod": ["pathoc", "pathod"]
}
if platform.system() == "Windows":
BDISTS["mitmproxy"].remove("mitmproxy")
TOOLS = [
tool
for tools in sorted(BDISTS.values())
for tool in tools
]
def git(args: str) -> str: def git(args: str) -> str:
@ -25,8 +81,50 @@ def git(args: str) -> str:
def get_version(dev: bool = False, build: bool = False) -> str: def get_version(dev: bool = False, build: bool = False) -> str:
x = runpy.run_path(VERSION_FILE) version = runpy.run_path(VERSION_FILE)["VERSION"]
return x["get_version"](dev, build, True) version = re.sub(r"\.dev.+?$", "", version) # replace dev suffix if present.
last_tag, tag_dist, commit = git("describe --tags --long").strip().rsplit("-", 2)
commit = commit.lstrip("g")[:7]
tag_dist = int(tag_dist)
if tag_dist > 0 and dev:
dev_tag = ".dev{tag_dist:04}".format(tag_dist=tag_dist)
else:
dev_tag = ""
if tag_dist > 0 and build:
# The wheel build tag (we use the commit) must start with a digit, so we include "0x"
build_tag = "-0x{commit}".format(commit=commit)
else:
build_tag = ""
return version + dev_tag + build_tag
def set_version(dev: bool) -> None:
"""
Update version information in mitmproxy's version.py to either include the dev version or not.
"""
v = get_version(dev)
with open(VERSION_FILE) as f:
content = f.read()
content = re.sub(r'^VERSION = ".+?"', 'VERSION = "{}"'.format(v), content)
with open(VERSION_FILE, "w") as f:
f.write(content)
def archive_name(bdist: str) -> str:
if platform.system() == "Windows":
ext = "zip"
else:
ext = "tar.gz"
return "{project}-{version}-{platform}.{ext}".format(
project=bdist,
version=get_version(),
platform=PLATFORM_TAG,
ext=ext
)
def wheel_name() -> str: def wheel_name() -> str:
@ -35,6 +133,19 @@ def wheel_name() -> str:
) )
def installer_name() -> str:
ext = {
"Windows": "exe",
"Darwin": "dmg",
"Linux": "run"
}[platform.system()]
return "mitmproxy-{version}-{platform}-installer.{ext}".format(
version=get_version(),
platform=PLATFORM_TAG,
ext=ext,
)
@contextlib.contextmanager @contextlib.contextmanager
def chdir(path: str): def chdir(path: str):
old_dir = os.getcwd() old_dir = os.getcwd()
@ -51,6 +162,24 @@ def cli():
pass pass
@cli.command("encrypt")
@click.argument('infile', type=click.File('rb'))
@click.argument('outfile', type=click.File('wb'))
@click.argument('key', envvar='RTOOL_KEY')
def encrypt(infile, outfile, key):
f = cryptography.fernet.Fernet(key.encode())
outfile.write(f.encrypt(infile.read()))
@cli.command("decrypt")
@click.argument('infile', type=click.File('rb'))
@click.argument('outfile', type=click.File('wb'))
@click.argument('key', envvar='RTOOL_KEY')
def decrypt(infile, outfile, key):
f = cryptography.fernet.Fernet(key.encode())
outfile.write(f.decrypt(infile.read()))
@cli.command("contributors") @cli.command("contributors")
def contributors(): def contributors():
""" """
@ -63,31 +192,184 @@ def contributors():
f.write(contributors_data.encode()) f.write(contributors_data.encode())
@cli.command("homebrew-pr") @cli.command("wheel")
def homebrew_pr(): def make_wheel():
""" """
Create a new Homebrew PR Build a Python wheel
""" """
if platform.system() != "Darwin": set_version(True)
print("You need to run this on macOS to create a new Homebrew PR. Sorry.") try:
sys.exit(1) subprocess.check_call([
"tox", "-e", "wheel",
], env={
**os.environ,
"VERSION": get_version(True),
})
finally:
set_version(False)
print("Creating a new PR with Homebrew...")
@cli.command("bdist")
def make_bdist():
"""
Build a binary distribution
"""
if exists(PYINSTALLER_TEMP):
shutil.rmtree(PYINSTALLER_TEMP)
if exists(PYINSTALLER_DIST):
shutil.rmtree(PYINSTALLER_DIST)
os.makedirs(DIST_DIR, exist_ok=True)
for bdist, tools in sorted(BDISTS.items()):
with Archive(join(DIST_DIR, archive_name(bdist))) as archive:
for tool in tools:
# We can't have a folder and a file with the same name.
if tool == "mitmproxy":
tool = "mitmproxy_main"
# This is PyInstaller, so it messes up paths.
# We need to make sure that we are in the spec folder.
with chdir(PYINSTALLER_SPEC):
print("Building %s binary..." % tool)
excludes = []
if tool != "mitmweb":
excludes.append("mitmproxy.tools.web")
if tool != "mitmproxy_main":
excludes.append("mitmproxy.tools.console")
# Overwrite mitmproxy/version.py to include commit info
set_version(True)
try:
subprocess.check_call(
[
"pyinstaller",
"--clean",
"--workpath", PYINSTALLER_TEMP,
"--distpath", PYINSTALLER_DIST,
"--additional-hooks-dir", PYINSTALLER_HOOKS,
"--onefile",
"--console",
"--icon", "icon.ico",
# This is PyInstaller, so setting a
# different log level obviously breaks it :-)
# "--log-level", "WARN",
]
+ [x for e in excludes for x in ["--exclude-module", e]]
+ PYINSTALLER_ARGS
+ [tool]
)
finally:
set_version(False)
# Delete the spec file - we're good without.
os.remove("{}.spec".format(tool))
# Test if it works at all O:-)
executable = join(PYINSTALLER_DIST, tool)
if platform.system() == "Windows":
executable += ".exe"
# Remove _main suffix from mitmproxy executable
if "_main" in executable:
shutil.move(
executable,
executable.replace("_main", "")
)
executable = executable.replace("_main", "")
print("> %s --version" % executable)
print(subprocess.check_output([executable, "--version"]).decode())
archive.add(executable, basename(executable))
print("Packed {}.".format(archive_name(bdist)))
@cli.command("upload-release")
@click.option('--username', prompt=True)
@click.password_option(confirmation_prompt=False)
@click.option('--repository', default="pypi")
def upload_release(username, password, repository):
"""
Upload wheels to PyPI
"""
filename = wheel_name()
print("Uploading {} to {}...".format(filename, repository))
subprocess.check_call([ subprocess.check_call([
"brew", "twine",
"bump-formula-pr", "upload",
"--url", "https://github.com/mitmproxy/mitmproxy/archive/v{}.tar.gz".format(get_version()), "-u", username,
"mitmproxy", "-p", password,
"-r", repository,
join(DIST_DIR, filename)
]) ])
@cli.command("encrypt") @cli.command("upload-snapshot")
@click.argument('infile', type=click.File('rb')) @click.option("--host", envvar="SNAPSHOT_HOST", prompt=True)
@click.argument('outfile', type=click.File('wb')) @click.option("--port", envvar="SNAPSHOT_PORT", type=int, default=22)
@click.argument('key', envvar='RTOOL_KEY') @click.option("--user", envvar="SNAPSHOT_USER", prompt=True)
def encrypt(infile, outfile, key): @click.option("--private-key", default=join(RELEASE_DIR, "rtool.pem"))
f = cryptography.fernet.Fernet(key.encode()) @click.option("--private-key-password", envvar="SNAPSHOT_PASS", prompt=True, hide_input=True)
outfile.write(f.encrypt(infile.read())) @click.option("--wheel/--no-wheel", default=False)
@click.option("--bdist/--no-bdist", default=False)
@click.option("--installer/--no-installer", default=False)
def upload_snapshot(host, port, user, private_key, private_key_password, wheel, bdist, installer):
"""
Upload snapshot to snapshot server
"""
with pysftp.Connection(host=host,
port=port,
username=user,
private_key=private_key,
private_key_pass=private_key_password) as sftp:
dir_name = "snapshots/v{}".format(get_version())
sftp.makedirs(dir_name)
with sftp.cd(dir_name):
files = []
if wheel:
files.append(wheel_name())
if bdist:
for bdist in sorted(BDISTS.keys()):
files.append(archive_name(bdist))
if installer:
files.append(installer_name())
for f in files:
local_path = join(DIST_DIR, f)
remote_filename = re.sub(
r"{version}(\.dev\d+(-0x[0-9a-f]+)?)?".format(version=get_version()),
get_version(True, True),
f
)
symlink_path = "../{}".format(f.replace(get_version(), "latest"))
# Upload new version
print("Uploading {} as {}...".format(f, remote_filename))
with click.progressbar(length=os.stat(local_path).st_size) as bar:
# We hide the file during upload
sftp.put(
local_path,
"." + remote_filename,
callback=lambda done, total: bar.update(done - bar.pos)
)
# Delete old versions
old_version = f.replace(get_version(), "*")
for f_old in sftp.listdir():
if fnmatch.fnmatch(f_old, old_version):
print("Removing {}...".format(f_old))
sftp.remove(f_old)
# Show new version
sftp.rename("." + remote_filename, remote_filename)
# update symlink for the latest release
if sftp.lexists(symlink_path):
print("Removing {}...".format(symlink_path))
sftp.remove(symlink_path)
if f != wheel_name():
# "latest" isn't a proper wheel version, so this could not be installed.
# https://github.com/mitmproxy/mitmproxy/issues/1065
sftp.symlink("v{}/{}".format(get_version(), remote_filename), symlink_path)
if __name__ == "__main__": if __name__ == "__main__":

14
tox.ini
View File

@ -33,15 +33,27 @@ commands =
python ./test/individual_coverage.py python ./test/individual_coverage.py
[testenv:cibuild] [testenv:cibuild]
passenv = TRAVIS_* AWS_* APPVEYOR_* RTOOL_KEY WHEEL passenv = TRAVIS_* AWS_* APPVEYOR_* TWINE_* RTOOL_KEY WHEEL
deps = deps =
-rrequirements.txt -rrequirements.txt
pyinstaller==3.3.1 pyinstaller==3.3.1
twine==1.11.0
awscli awscli
commands = commands =
mitmdump --version mitmdump --version
python ./release/ci.py {posargs} python ./release/ci.py {posargs}
[testenv:wheeltest]
recreate = True
deps =
commands =
pip install {posargs}
mitmproxy --version
mitmdump --version
mitmweb --version
pathod --version
pathoc --version
[testenv:docs] [testenv:docs]
passenv = TRAVIS_* AWS_* APPVEYOR_* RTOOL_KEY WHEEL passenv = TRAVIS_* AWS_* APPVEYOR_* RTOOL_KEY WHEEL
deps = deps =