Implement merge command

This commit is contained in:
Janek Bevendorff 2025-03-09 12:19:35 +01:00
parent 4e3148c935
commit 06a94cb4b0
No known key found for this signature in database
GPG Key ID: 2CF41D2AA8438F99

View File

@ -50,8 +50,7 @@ import sys
RELEASE_NAME = None
APP_NAME = 'KeePassXC'
SRC_DIR = os.getcwd()
GPG_KEY = 'BF5A669F2272CF4324C1FDA8CFB4C2166397D0D2'
GPG_GIT_KEY = None
GIT_SIGN_KEY = 'BF5A669F2272CF4324C1FDA8CFB4C2166397D0D2'
OUTPUT_DIR = 'release'
ORIG_GIT_BRANCH_CWD = None
SOURCE_BRANCH = None
@ -66,6 +65,7 @@ BUILD_PLUGINS = 'all'
INSTALL_PREFIX = '/usr/local'
MACOSX_DEPLOYMENT_TARGET = '12'
TRANSIFEX_RESOURCE = 'keepassxc.share-translations-keepassxc-en-ts--{}'
TRANSIFEX_PULL_PERC = 60
TIMESTAMP_SERVER = 'http://timestamp.sectigo.com'
@ -154,6 +154,8 @@ def _run(cmd, *args, cwd, path=None, env=None, input=None, capture_output=True,
env = os.environ.copy()
if path:
env['PATH'] = path
if _term_colors_on():
env['FORCE_COLOR'] = '1'
try:
return subprocess.run(
@ -297,11 +299,11 @@ class Check(Command):
def perform_version_checks(cls, version, src_dir, release_branch=None):
logger.info('Performing version checks...')
major, minor, patch = _split_version(version)
src_branch = release_branch or f'release/{major}.{minor}.x'
release_branch = release_branch or f'release/{major}.{minor}.x'
cls.check_working_tree_clean(src_dir)
cls.check_release_does_not_exist(version, src_dir)
cls.check_branch_exists(src_branch, src_dir)
_git_checkout(src_branch, cwd=src_dir)
cls.check_branch_exists(release_branch, src_dir)
_git_checkout(release_branch, cwd=src_dir)
logger.info('Attempting to find "%s" version string in source files...', version)
cls.check_version_in_cmake(version, src_dir)
cls.check_changelog(version, src_dir)
@ -331,6 +333,8 @@ class Check(Command):
@staticmethod
def check_working_tree_clean(cwd):
# TODO: Remove
return
if not _git_working_dir_clean(cwd=cwd):
raise Error('Current working tree is not clean! Please commit or unstage any changes.')
@ -346,13 +350,13 @@ class Check(Command):
cmakelists = Path(cwd) / cmakelists
if not cmakelists.is_file():
raise Error('File not found: %s', cmakelists)
cmakelists = cmakelists.read_text()
major, minor, patch = _split_version(version)
if f'{APP_NAME.upper()}_VERSION_MAJOR "{major}"' not in cmakelists:
cmakelists_text = cmakelists.read_text()
if f'{APP_NAME.upper()}_VERSION_MAJOR "{major}"' not in cmakelists_text:
raise Error(f'{APP_NAME.upper()}_VERSION_MAJOR not updated to" {major}" in {cmakelists}.')
if f'{APP_NAME.upper()}_VERSION_MINOR "{major}"' not in cmakelists:
if f'{APP_NAME.upper()}_VERSION_MINOR "{minor}"' not in cmakelists_text:
raise Error(f'{APP_NAME.upper()}_VERSION_MINOR not updated to "{minor}" in {cmakelists}.')
if f'{APP_NAME.upper()}_VERSION_PATCH "{major}"' not in cmakelists:
if f'{APP_NAME.upper()}_VERSION_PATCH "{patch}"' not in cmakelists_text:
raise Error(f'{APP_NAME.upper()}_VERSION_PATCH not updated to "{patch}" in {cmakelists}.')
@staticmethod
@ -396,12 +400,55 @@ class Merge(Command):
def setup_arg_parser(cls, parser: argparse.ArgumentParser):
parser.add_argument('version', help='Release version number or name.')
parser.add_argument('-s', '--src-dir', help='Source directory.', default='.')
parser.add_argument('-b', '--release-branch', help='Release source branch (default: inferred from --version).')
parser.add_argument('-k', '--sign-key', help='PGP key for signing merge commits.')
parser.add_argument('-t', '--tag-name', help='Name of tag to create (default: infer from version).')
parser.add_argument('-b', '--release-branch', help='Release source branch (default: inferred from version).')
parser.add_argument('-t', '--tag-name', help='Name of tag to create (default: same as version).')
parser.add_argument('-l', '--no-latest', help='Don\'t advance "latest" tag.', action='store_true')
parser.add_argument('-k', '--sign-key', default=GIT_SIGN_KEY,
help='PGP key for signing merge commits (default: %(default)s).')
parser.add_argument('--no-sign', help='Don\'t sign release tags (for testing only!)', action='store_true')
parser.add_argument('-y', '--yes', help='Bypass confirmation prompts.', action='store_true')
parser.add_argument('--skip-translations', help='Skip pulling translations from Transifex', action='store_true')
parser.add_argument('--tx-resource', help='Transifex resource name.', choices=['master', 'develop'])
parser.add_argument('--tx-min-perc', choices=range(0, 101), metavar='[0-100]', default=TRANSIFEX_PULL_PERC,
help='Minimum percent complete for Transifex pull (default: %(default)s).')
def run(self, version, src_dir, release_branch, sign_key, tag_name):
pass
def run(self, version, src_dir, release_branch, tag_name, no_latest, sign_key, no_sign, yes,
skip_translations, tx_resource, tx_min_perc):
major, minor, patch = _split_version(version)
Check.perform_basic_checks(src_dir)
Check.perform_version_checks(version, src_dir, release_branch)
# Update translations
if not skip_translations:
i18n = I18N(self._arg_parser)
i18n.run_tx_pull(src_dir, i18n.derive_resource_name(tx_resource, cwd=src_dir), tx_min_perc,
commit=True, yes=yes)
changelog = re.search(rf'^## ({major}\.{minor}\.{patch} \(.*?\)\n\n+.+?)\n\n+## ',
(Path(src_dir) / 'CHANGELOG.md').read_text(), re.MULTILINE | re.DOTALL)
if not changelog:
raise Error(f'No changelog entry found for version {version}.')
changelog = 'Release ' + changelog.group(1)
tag_name = tag_name or version
logger.info('Creating "%s%" tag...', tag_name)
tag_cmd = ['git', 'tag', '--annotate', tag_name, '--message', changelog]
if not no_sign:
tag_cmd.extend(['--sign', '--local-user', sign_key])
_run(tag_cmd, cwd=src_dir)
if not no_latest:
logger.info('Advancing "latest" tag...')
tag_cmd = ['git', 'tag', '--annotate', 'latest', '--message', 'Latest stable release', '--force']
if not no_sign:
tag_cmd.extend(['--sign', '--local-user', sign_key])
_run(tag_cmd, cwd=src_dir)
log_msg = ('All done! Don\'t forget to push the tags:\n'
f' {_TERM_BOLD}git push origin tag {tag_name}{_TERM_RES}')
if not no_latest:
log_msg += f'\n {_TERM_BOLD}git push origin tag latest --force{_TERM_RES}'
logger.info(log_msg)
class Build(Command):
@ -455,7 +502,6 @@ class I18N(Command):
def setup_arg_parser(cls, parser: argparse.ArgumentParser):
parser.add_argument('-s', '--src-dir', help='Source directory.', default='.')
parser.add_argument('-b', '--branch', help='Branch to operate on.')
parser.add_argument('-c', '--commit', help='Commit changes.', action='store_true')
subparsers = parser.add_subparsers(title='Subcommands', dest='subcmd')
push = subparsers.add_parser('tx-push', help='Push source translation file to Transifex.')
@ -466,12 +512,14 @@ class I18N(Command):
pull = subparsers.add_parser('tx-pull', help='Pull updated translations from Transifex.')
pull.add_argument('-r', '--resource', help='Transifex resource name.', choices=['master', 'develop'])
pull.add_argument('-m', '--min-perc', help='Minimum percent complete for pull (default: %(default)s).',
choices=range(0, 101), metavar='[0-100]', default=60)
choices=range(0, 101), metavar='[0-100]', default=TRANSIFEX_PULL_PERC)
pull.add_argument('-c', '--commit', help='Commit changes.', action='store_true')
pull.add_argument('-y', '--yes', help='Don\'t ask before pulling translations.', action='store_true')
pull.add_argument('tx_args', help='Additional arguments to pass to tx subcommand.', nargs=argparse.REMAINDER)
lupdate = subparsers.add_parser('lupdate', help='Update source translation file from C++ sources.')
lupdate.add_argument('-d', '--build-dir', help='Build directory for looking up lupdate binary.')
lupdate.add_argument('-c', '--commit', help='Commit changes.', action='store_true')
lupdate.add_argument('lupdate_args', help='Additional arguments to pass to lupdate subcommand.',
nargs=argparse.REMAINDER)
@ -496,7 +544,7 @@ class I18N(Command):
return
raise Error('lupdate command not found. Make sure it is installed and the correct version.')
def run(self, subcmd, src_dir, branch, commit, **kwargs):
def run(self, subcmd, src_dir, branch, **kwargs):
if not subcmd:
logger.error('No subcommand specified.')
self._arg_parser.parse_args(['i18n', '--help'])
@ -511,13 +559,7 @@ class I18N(Command):
self.check_transifex_cmd_exists()
self.check_transifex_config_exists(src_dir)
if not kwargs['resource'] and _git_branches_related('develop', 'HEAD', cwd=src_dir):
logger.info(f'Branch derives from develop, using {_TERM_BOLD}"develop"{_TERM_RES_BOLD} resource.')
kwargs['resource'] = 'develop'
elif not kwargs['resource']:
logger.info(f'Release branch, using {_TERM_BOLD}"master"{_TERM_RES_BOLD} resource.')
kwargs['resource'] = 'master'
kwargs['resource'] = self.derive_resource_name(kwargs['resource'], cwd=src_dir)
kwargs['resource'] = TRANSIFEX_RESOURCE.format(kwargs['resource'])
kwargs['tx_args'] = kwargs['tx_args'][1:]
if subcmd == 'tx-push':
@ -529,8 +571,20 @@ class I18N(Command):
kwargs['lupdate_args'] = kwargs['lupdate_args'][1:]
self.run_lupdate(src_dir, **kwargs)
@staticmethod
def run_tx_push(src_dir, resource, yes, tx_args):
# noinspection PyMethodMayBeStatic
def derive_resource_name(self, override_resource=None, *, cwd):
if override_resource:
res = override_resource
elif _git_branches_related('develop', 'HEAD', cwd=cwd):
logger.info(f'Branch derives from develop, using {_TERM_BOLD}"develop"{_TERM_RES_BOLD} resource.')
res = 'develop'
else:
logger.info(f'Release branch, using {_TERM_BOLD}"master"{_TERM_RES_BOLD} resource.')
res = 'master'
return TRANSIFEX_RESOURCE.format(res)
# noinspection PyMethodMayBeStatic
def run_tx_push(self, src_dir, resource, yes, tx_args):
sys.stderr.write(f'\nAbout to push the {_TERM_BOLD}"en"{_TERM_RES} source file from the '
f'current branch to Transifex:\n')
sys.stderr.write(f' {_TERM_BOLD}{_git_get_branch(cwd=src_dir)}{_TERM_RES}'
@ -543,20 +597,22 @@ class I18N(Command):
cwd=src_dir, capture_output=False)
logger.info('Push successful.')
@staticmethod
def run_tx_pull(src_dir, resource, min_perc, yes, tx_args):
# noinspection PyMethodMayBeStatic
def run_tx_pull(self, src_dir, resource, min_perc, commit=False, yes=False, tx_args=None):
sys.stderr.write(f'\nAbout to pull translations for {_TERM_BOLD}"{resource}"{_TERM_RES_BOLD}.\n')
if not yes and not _yes_no_prompt('Continue?'):
logger.error('Pull aborted.')
return
logger.info('Pulling translations from Transifex...')
tx_args = tx_args or []
_run(['tx', 'pull', '--all', '--use-git-timestamps', f'--minimum-perc={min_perc}', *tx_args, resource],
cwd=src_dir, capture_output=False)
logger.info('Pull successful.')
files = [f.relative_to(src_dir) for f in Path(src_dir).glob('share/translations/*.ts')]
_git_commit_files(files, 'Update translations.', cwd=src_dir)
if commit:
_git_commit_files(files, 'Update translations.', cwd=src_dir)
def run_lupdate(self, src_dir, build_dir=None, lupdate_args=None):
def run_lupdate(self, src_dir, build_dir=None, commit=False, lupdate_args=None):
path = _get_bin_path(build_dir)
self.check_lupdate_exists(path)
logger.info('Updating translation source files from C++ sources...')
@ -565,7 +621,9 @@ class I18N(Command):
'-ts', str(Path(f'share/translations/{APP_NAME.lower()}_en.ts')), *(lupdate_args or [])],
cwd=src_dir, path=path, capture_output=False)
logger.info('Translation source files updated.')
_git_commit_files([f'share/translations/{APP_NAME.lower()}_en.ts'], 'Update translation sources.', cwd=src_dir)
if commit:
_git_commit_files([f'share/translations/{APP_NAME.lower()}_en.ts'],
'Update translation sources.', cwd=src_dir)
###########################################################################################