#!/usr/bin/env python3 # # Copyright (C) 2023 KeePassXC Team # # This program 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 2 or (at your option) # version 3 of the License. # # This program 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 this program. If not, see . import argparse import ctypes from datetime import datetime import logging import os import platform import shutil import subprocess import sys ########################################################################################### # Globals ########################################################################################### RELEASE_NAME = None APP_NAME = 'KeePassXC' SRC_DIR = os.getcwd() GPG_KEY = 'BF5A669F2272CF4324C1FDA8CFB4C2166397D0D2' GPG_GIT_KEY = None OUTPUT_DIR = 'release' ORIG_GIT_BRANCH = None SOURCE_BRANCH = None TAG_NAME = None DOCKER_IMAGE = None DOCKER_CONTAINER_NAME = 'keepassxc-build-container' CMAKE_OPTIONS = [] CPACK_GENERATORS = 'WIX;ZIP' COMPILER = 'g++' MAKE_OPTIONS = f'-j{os.cpu_count()}' BUILD_PLUGINS = 'all' INSTALL_PREFIX = '/usr/local' MACOSX_DEPLOYMENT_TARGET = '10.15' TIMESTAMP_SERVER = 'http://timestamp.sectigo.com' ########################################################################################### # Errors and Logging ########################################################################################### class Error(Exception): def __init__(self, msg, *args, **kwargs): self.msg = msg self.args = args self.kwargs = kwargs self.__dict__.update(kwargs) class SubprocessError(Error): pass class LogFormatter(logging.Formatter): _CLR = 'color' in os.getenv('TERM', '') or 'CLICOLOR_FORCE' in os.environ or sys.platform == 'win32' BOLD = '\x1b[1m' if _CLR else '' RED = '\x1b[31m' if _CLR else '' BRIGHT_RED = '\x1b[91m' if _CLR else '' YELLOW = '\x1b[33m' if _CLR else '' BLUE = '\x1b[34m' if _CLR else '' GREEN = '\x1b[32m' if _CLR else '' END = '\x1b[0m' if _CLR else '' _FMT = { logging.DEBUG: f'{BOLD}[%(levelname)s{END}{BOLD}]{END} %(message)s', logging.INFO: f'{BOLD}[{BLUE}%(levelname)s{END}{BOLD}]{END} %(message)s', logging.WARNING: f'{BOLD}[{YELLOW}%(levelname)s{END}{BOLD}]{END}{YELLOW} %(message)s{END}', logging.ERROR: f'{BOLD}[{RED}%(levelname)s{END}{BOLD}]{END}{RED} %(message)s{END}', logging.CRITICAL: f'{BOLD}[{BRIGHT_RED}%(levelname)s{END}{BOLD}]{END}{BRIGHT_RED} %(message)s{END}', } def format(self, record): return logging.Formatter(self._FMT.get(record.levelno)).format(record) console_handler = logging.StreamHandler() console_handler.setFormatter(LogFormatter()) logger = logging.getLogger(__file__) logger.setLevel(os.getenv('LOGLEVEL') if 'LOGLEVEL' in os.environ else logging.INFO) logger.addHandler(console_handler) ########################################################################################### # Helper Functions ########################################################################################### def _run(cmd, *args, input=None, capture_output=True, timeout=None, check=False, **kwargs): """ Run a command and return its output. Raises an error if ``check`` is ``True`` and the process exited with a non-zero code. """ if not cmd: raise ValueError('Empty command given.') try: return subprocess.run( cmd, *args, input=input, capture_output=capture_output, timeout=timeout, check=check, **kwargs) except FileNotFoundError: raise Error('Command not found: %s', cmd[0] if type(cmd) in [list, tuple] else cmd) except subprocess.CalledProcessError as e: if e.stderr: raise SubprocessError('Command \'%s\' exited with non-zero code. Error: %s', cmd[0], e.stderr, **e.__dict__) else: raise SubprocessError('Command \'%s\' exited with non-zero code.', cmd[0], **e.__dict__) def _cmd_exists(cmd): """Check if command exists.""" return shutil.which(cmd) is not None def _git_get_branch(): """Get current Git branch.""" branch = _run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], check=True).stdout.decode() global ORIG_GIT_BRANCH if not ORIG_GIT_BRANCH: ORIG_GIT_BRANCH = branch return branch def _git_checkout(branch): """Check out Git branch.""" try: _run(['git', 'checkout', branch], check=True) except SubprocessError as e: raise Error('Failed to check out branch \'%s\'. %s', branch, e.stderr.decode().capitalize()) def _cleanup(): """Post-execution cleanup.""" try: if ORIG_GIT_BRANCH: logger.info('Checking out original branch...') # _git_checkout(ORIG_GIT_BRANCH) return 0 except Exception as e: logger.critical('Exception occurred during cleanup:', exc_info=e) return 1 ########################################################################################### # CLI Commands ########################################################################################### class Command: """Command base class.""" @classmethod def setup_arg_parser(cls, parser: argparse.ArgumentParser): pass def run(self, **kwargs): pass class Check(Command): """Perform a dry-run check, nothing is changed.""" @classmethod def setup_arg_parser(cls, parser: argparse.ArgumentParser): pass def run(self, version): print(version) class Merge(Command): """Merge release branch into main branch and create release tags.""" @classmethod def setup_arg_parser(cls, parser: argparse.ArgumentParser): parser.add_argument('version', help='Release version number or name') def run(self, version): print(version) class Build(Command): """Build and package binary release from sources.""" @classmethod def setup_arg_parser(cls, parser: argparse.ArgumentParser): pass def run(self, **kwargs): pass class GPGSign(Command): """Sign previously compiled release packages with GPG.""" @classmethod def setup_arg_parser(cls, parser: argparse.ArgumentParser): pass def run(self, **kwargs): pass class AppSign(Command): """Sign binaries with code signing certificates on Windows and macOS.""" @classmethod def setup_arg_parser(cls, parser: argparse.ArgumentParser): pass def run(self, **kwargs): pass class Notarize(Command): """Submit macOS application DMG for notarization.""" @classmethod def setup_arg_parser(cls, parser: argparse.ArgumentParser): pass def run(self, **kwargs): pass class I18N(Command): """Update translation files and pull from or push to Transifex.""" @classmethod def setup_arg_parser(cls, parser: argparse.ArgumentParser): pass def run(self, **kwargs): pass ########################################################################################### # CLI Main ########################################################################################### def main(): if sys.platform == 'win32': # Enable terminal colours ctypes.windll.kernel32.SetConsoleMode(ctypes.windll.kernel32.GetStdHandle(-11), 7) sys.stderr.write(f'{LogFormatter.BOLD}{LogFormatter.GREEN}KeePassXC{LogFormatter.END}' f'{LogFormatter.BOLD} Release Preparation Tool{LogFormatter.END}\n') sys.stderr.write(f'Copyright (C) 2016-{datetime.now().year} KeePassXC Team \n\n') parser = argparse.ArgumentParser(add_help=True) subparsers = parser.add_subparsers(title='commands') subparsers.required = True check_parser = subparsers.add_parser('check', help=Check.__doc__) Check.setup_arg_parser(check_parser) check_parser.set_defaults(_cmd=Check) merge_parser = subparsers.add_parser('merge', help=Merge.__doc__) Merge.setup_arg_parser(merge_parser) merge_parser.set_defaults(_cmd=Merge) build_parser = subparsers.add_parser('build', help=Build.__doc__) Build.setup_arg_parser(build_parser) build_parser.set_defaults(_cmd=Build) gpgsign_parser = subparsers.add_parser('gpgsign', help=GPGSign.__doc__) Merge.setup_arg_parser(gpgsign_parser) gpgsign_parser.set_defaults(_cmd=GPGSign) appsign_parser = subparsers.add_parser('appsign', help=AppSign.__doc__) AppSign.setup_arg_parser(appsign_parser) appsign_parser.set_defaults(_cmd=AppSign) notarize_parser = subparsers.add_parser('notarize', help=Notarize.__doc__) Notarize.setup_arg_parser(notarize_parser) notarize_parser.set_defaults(cm_cmdd=Notarize) i18n_parser = subparsers.add_parser('i18n', help=I18N.__doc__) I18N.setup_arg_parser(i18n_parser) i18n_parser.set_defaults(_cmd=I18N) args = parser.parse_args() args._cmd().run(**{k: v for k, v in vars(args).items() if k is not '_cmd'}) if __name__ == '__main__': ret = 0 try: main() except Error as e: logger.error(e.msg, *e.args, extra=e.kwargs) ret = 1 except KeyboardInterrupt: logger.warning('Process interrupted.') ret = 3 except SystemExit as e: ret = e.code except Exception as e: logger.critical('Unhandled exception:', exc_info=e) ret = 4 finally: ret |= _cleanup() sys.exit(ret)