diff options
Diffstat (limited to '.config/qutebrowser/scripts/dev/build_release.py')
| -rwxr-xr-x | .config/qutebrowser/scripts/dev/build_release.py | 419 |
1 files changed, 419 insertions, 0 deletions
diff --git a/.config/qutebrowser/scripts/dev/build_release.py b/.config/qutebrowser/scripts/dev/build_release.py new file mode 100755 index 0000000..254132b --- /dev/null +++ b/.config/qutebrowser/scripts/dev/build_release.py @@ -0,0 +1,419 @@ +#!/usr/bin/env python3 +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2014-2018 Florian Bruhin (The Compiler) <mail@qutebrowser.org> +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. + +"""Build a new release.""" + + +import os +import os.path +import sys +import time +import glob +import shutil +import plistlib +import subprocess +import argparse +import tarfile +import tempfile +import collections + +try: + import winreg +except ImportError: + pass + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, + os.pardir)) + +import qutebrowser +from scripts import utils +# from scripts.dev import update_3rdparty + + +def call_script(name, *args, python=sys.executable): + """Call a given shell script. + + Args: + name: The script to call. + *args: The arguments to pass. + python: The python interpreter to use. + """ + path = os.path.join(os.path.dirname(__file__), os.pardir, name) + subprocess.run([python, path] + list(args), check=True) + + +def call_tox(toxenv, *args, python=sys.executable): + """Call tox. + + Args: + toxenv: Which tox environment to use + *args: The arguments to pass. + python: The python interpreter to use. + """ + env = os.environ.copy() + env['PYTHON'] = python + env['PATH'] = os.environ['PATH'] + os.pathsep + os.path.dirname(python) + subprocess.run( + [sys.executable, '-m', 'tox', '-vv', '-e', toxenv] + list(args), + env=env, check=True) + + +def run_asciidoc2html(args): + """Common buildsteps used for all OS'.""" + utils.print_title("Running asciidoc2html.py") + if args.asciidoc is not None: + a2h_args = ['--asciidoc'] + args.asciidoc + else: + a2h_args = [] + call_script('asciidoc2html.py', *a2h_args) + + +def _maybe_remove(path): + """Remove a path if it exists.""" + try: + shutil.rmtree(path) + except FileNotFoundError: + pass + + +def smoke_test(executable): + """Try starting the given qutebrowser executable.""" + subprocess.run([executable, '--no-err-windows', '--nowindow', + '--temp-basedir', 'about:blank', ':later 500 quit'], + check=True) + + +def patch_mac_app(): + """Patch .app to copy missing data and link some libs. + + See https://github.com/pyinstaller/pyinstaller/issues/2276 + """ + app_path = os.path.join('dist', 'qutebrowser.app') + qtwe_core_dir = os.path.join('.tox', 'pyinstaller', 'lib', 'python3.6', + 'site-packages', 'PyQt5', 'Qt', 'lib', + 'QtWebEngineCore.framework') + # Copy QtWebEngineProcess.app + proc_app = 'QtWebEngineProcess.app' + shutil.copytree(os.path.join(qtwe_core_dir, 'Helpers', proc_app), + os.path.join(app_path, 'Contents', 'MacOS', proc_app)) + # Copy resources + for f in glob.glob(os.path.join(qtwe_core_dir, 'Resources', '*')): + dest = os.path.join(app_path, 'Contents', 'Resources') + if os.path.isdir(f): + dir_dest = os.path.join(dest, os.path.basename(f)) + print("Copying directory {} to {}".format(f, dir_dest)) + shutil.copytree(f, dir_dest) + else: + print("Copying {} to {}".format(f, dest)) + shutil.copy(f, dest) + # Link dependencies + for lib in ['QtCore', 'QtWebEngineCore', 'QtQuick', 'QtQml', 'QtNetwork', + 'QtGui', 'QtWebChannel', 'QtPositioning']: + dest = os.path.join(app_path, lib + '.framework', 'Versions', '5') + os.makedirs(dest) + os.symlink(os.path.join(os.pardir, os.pardir, os.pardir, 'Contents', + 'MacOS', lib), + os.path.join(dest, lib)) + # Patch Info.plist - pyinstaller's options are too limiting + plist_path = os.path.join(app_path, 'Contents', 'Info.plist') + with open(plist_path, "rb") as f: + plist_data = plistlib.load(f) + plist_data.update(INFO_PLIST_UPDATES) + with open(plist_path, "wb") as f: + plistlib.dump(plist_data, f) + + +INFO_PLIST_UPDATES = { + 'CFBundleVersion': qutebrowser.__version__, + 'CFBundleShortVersionString': qutebrowser.__version__, + 'NSSupportsAutomaticGraphicsSwitching': True, + 'NSHighResolutionCapable': True, + 'CFBundleURLTypes': [{ + "CFBundleURLName": "http(s) URL", + "CFBundleURLSchemes": ["http", "https"] + }, { + "CFBundleURLName": "local file URL", + "CFBundleURLSchemes": ["file"] + }], + 'CFBundleDocumentTypes': [{ + "CFBundleTypeExtensions": ["html", "htm"], + "CFBundleTypeMIMETypes": ["text/html"], + "CFBundleTypeName": "HTML document", + "CFBundleTypeOSTypes": ["HTML"], + "CFBundleTypeRole": "Viewer", + }, { + "CFBundleTypeExtensions": ["xhtml"], + "CFBundleTypeMIMETypes": ["text/xhtml"], + "CFBundleTypeName": "XHTML document", + "CFBundleTypeRole": "Viewer", + }] +} + + +def build_mac(): + """Build macOS .dmg/.app.""" + utils.print_title("Cleaning up...") + for f in ['wc.dmg', 'template.dmg']: + try: + os.remove(f) + except FileNotFoundError: + pass + for d in ['dist', 'build']: + shutil.rmtree(d, ignore_errors=True) + utils.print_title("Updating 3rdparty content") + # Currently disabled because QtWebEngine has no pdfjs support + # update_3rdparty.run(ace=False, pdfjs=True, fancy_dmg=False) + utils.print_title("Building .app via pyinstaller") + call_tox('pyinstaller', '-r') + utils.print_title("Patching .app") + patch_mac_app() + utils.print_title("Building .dmg") + subprocess.run(['make', '-f', 'scripts/dev/Makefile-dmg'], check=True) + + dmg_name = 'qutebrowser-{}.dmg'.format(qutebrowser.__version__) + os.rename('qutebrowser.dmg', dmg_name) + + utils.print_title("Running smoke test") + + try: + with tempfile.TemporaryDirectory() as tmpdir: + subprocess.run(['hdiutil', 'attach', dmg_name, + '-mountpoint', tmpdir], check=True) + try: + binary = os.path.join(tmpdir, 'qutebrowser.app', 'Contents', + 'MacOS', 'qutebrowser') + smoke_test(binary) + finally: + time.sleep(5) + subprocess.run(['hdiutil', 'detach', tmpdir]) + except PermissionError as e: + print("Failed to remove tempdir: {}".format(e)) + + return [(dmg_name, 'application/x-apple-diskimage', 'macOS .dmg')] + + +def patch_windows(out_dir): + """Copy missing DLLs for windows into the given output.""" + dll_dir = os.path.join('.tox', 'pyinstaller', 'lib', 'site-packages', + 'PyQt5', 'Qt', 'bin') + dlls = ['libEGL.dll', 'libGLESv2.dll', 'libeay32.dll', 'ssleay32.dll'] + for dll in dlls: + shutil.copy(os.path.join(dll_dir, dll), out_dir) + + +def build_windows(): + """Build windows executables/setups.""" + utils.print_title("Updating 3rdparty content") + # Currently disabled because QtWebEngine has no pdfjs support + # update_3rdparty.run(ace=False, pdfjs=True, fancy_dmg=False) + + utils.print_title("Building Windows binaries") + parts = str(sys.version_info.major), str(sys.version_info.minor) + ver = ''.join(parts) + dot_ver = '.'.join(parts) + + # Get python path from registry if possible + try: + reg64_key = winreg.OpenKeyEx(winreg.HKEY_LOCAL_MACHINE, + r'SOFTWARE\Python\PythonCore' + r'\{}\InstallPath'.format(dot_ver)) + python_x64 = winreg.QueryValueEx(reg64_key, 'ExecutablePath')[0] + except FileNotFoundError: + python_x64 = r'C:\Python{}\python.exe'.format(ver) + + out_pyinstaller = os.path.join('dist', 'qutebrowser') + out_64 = os.path.join('dist', + 'qutebrowser-{}-x64'.format(qutebrowser.__version__)) + + artifacts = [] + + from scripts.dev import gen_versioninfo + utils.print_title("Updating VersionInfo file") + gen_versioninfo.main() + + utils.print_title("Running pyinstaller 64bit") + _maybe_remove(out_64) + call_tox('pyinstaller', '-r', python=python_x64) + shutil.move(out_pyinstaller, out_64) + patch_windows(out_64) + + utils.print_title("Building installers") + subprocess.run(['makensis.exe', + '/DX64', + '/DVERSION={}'.format(qutebrowser.__version__), + 'misc/qutebrowser.nsi'], check=True) + + name_64 = 'qutebrowser-{}-amd64.exe'.format(qutebrowser.__version__) + + artifacts += [ + (os.path.join('dist', name_64), + 'application/vnd.microsoft.portable-executable', + 'Windows 64bit installer'), + ] + + utils.print_title("Running 64bit smoke test") + smoke_test(os.path.join(out_64, 'qutebrowser.exe')) + + utils.print_title("Zipping 64bit standalone...") + name = 'qutebrowser-{}-windows-standalone-amd64'.format( + qutebrowser.__version__) + shutil.make_archive(name, 'zip', 'dist', os.path.basename(out_64)) + artifacts.append(('{}.zip'.format(name), + 'application/zip', + 'Windows 64bit standalone')) + + return artifacts + + +def build_sdist(): + """Build an sdist and list the contents.""" + utils.print_title("Building sdist") + + _maybe_remove('dist') + + subprocess.run([sys.executable, 'setup.py', 'sdist'], check=True) + dist_files = os.listdir(os.path.abspath('dist')) + assert len(dist_files) == 1 + + dist_file = os.path.join('dist', dist_files[0]) + subprocess.run(['gpg', '--detach-sign', '-a', dist_file], check=True) + + tar = tarfile.open(dist_file) + by_ext = collections.defaultdict(list) + + for tarinfo in tar.getmembers(): + if not tarinfo.isfile(): + continue + name = os.sep.join(tarinfo.name.split(os.sep)[1:]) + _base, ext = os.path.splitext(name) + by_ext[ext].append(name) + + assert '.pyc' not in by_ext + + utils.print_title("sdist contents") + + for ext, files in sorted(by_ext.items()): + utils.print_subtitle(ext) + print('\n'.join(files)) + + filename = 'qutebrowser-{}.tar.gz'.format(qutebrowser.__version__) + artifacts = [ + (os.path.join('dist', filename), 'application/gzip', 'Source release'), + (os.path.join('dist', filename + '.asc'), 'application/pgp-signature', + 'Source release - PGP signature'), + ] + + return artifacts + + +def test_makefile(): + """Make sure the Makefile works correctly.""" + utils.print_title("Testing makefile") + with tempfile.TemporaryDirectory() as tmpdir: + subprocess.run(['make', '-f', 'misc/Makefile', + 'DESTDIR={}'.format(tmpdir), 'install'], check=True) + + +def read_github_token(): + """Read the GitHub API token from disk.""" + token_file = os.path.join(os.path.expanduser('~'), '.gh_token') + with open(token_file, encoding='ascii') as f: + token = f.read().strip() + return token + + +def github_upload(artifacts, tag): + """Upload the given artifacts to GitHub. + + Args: + artifacts: A list of (filename, mimetype, description) tuples + tag: The name of the release tag + """ + import github3 + utils.print_title("Uploading to github...") + + token = read_github_token() + gh = github3.login(token=token) + repo = gh.repository('qutebrowser', 'qutebrowser') + + release = None # to satisfy pylint + for release in repo.releases(): + if release.tag_name == tag: + break + else: + raise Exception("No release found for {!r}!".format(tag)) + + for filename, mimetype, description in artifacts: + with open(filename, 'rb') as f: + basename = os.path.basename(filename) + asset = release.upload_asset(mimetype, basename, f) + asset.edit(basename, description) + + +def pypi_upload(artifacts): + """Upload the given artifacts to PyPI using twine.""" + filenames = [a[0] for a in artifacts] + subprocess.run(['twine', 'upload'] + filenames, check=True) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--asciidoc', help="Full path to python and " + "asciidoc.py. If not given, it's searched in PATH.", + nargs=2, required=False, + metavar=('PYTHON', 'ASCIIDOC')) + parser.add_argument('--upload', help="Tag to upload the release for", + nargs=1, required=False, metavar='TAG') + args = parser.parse_args() + utils.change_cwd() + + upload_to_pypi = False + + if args.upload is not None: + # Fail early when trying to upload without github3 installed + # or without API token + import github3 # pylint: disable=unused-variable + read_github_token() + + run_asciidoc2html(args) + if os.name == 'nt': + artifacts = build_windows() + elif sys.platform == 'darwin': + artifacts = build_mac() + else: + test_makefile() + artifacts = build_sdist() + upload_to_pypi = True + + if args.upload is not None: + utils.print_title("Press enter to release...") + input() + github_upload(artifacts, args.upload[0]) + if upload_to_pypi: + pypi_upload(artifacts) + else: + print() + utils.print_title("Artifacts") + for artifact in artifacts: + print(artifact) + + +if __name__ == '__main__': + main() |
