koji_build.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. #!/usr/bin/env python3
  2. import argparse
  3. import logging
  4. import os
  5. import re
  6. import subprocess
  7. from contextlib import contextmanager
  8. from datetime import datetime, timedelta
  9. from pathlib import Path
  10. try:
  11. from specfile import Specfile
  12. except ImportError:
  13. print("error: specfile module can't be imported. Please install it with 'pip install --user specfile'.")
  14. exit(1)
  15. TIME_FORMAT = '%Y-%m-%d-%H-%M-%S'
  16. @contextmanager
  17. def cd(dir):
  18. """Change to a directory temporarily. To be used in a with statement."""
  19. prevdir = os.getcwd()
  20. os.chdir(dir)
  21. try:
  22. yield os.path.realpath(dir)
  23. finally:
  24. os.chdir(prevdir)
  25. def check_dir(dirpath):
  26. if not os.path.isdir(dirpath):
  27. raise Exception("Directory %s doesn't exist" % dirpath)
  28. return dirpath
  29. def check_git_repo(dirpath):
  30. """check that the working copy is a working directory and is clean."""
  31. with cd(dirpath):
  32. return subprocess.run(['git', 'diff-index', '--quiet', 'HEAD', '--']).returncode == 0
  33. def check_commit_is_available_remotely(dirpath, hash):
  34. with cd(dirpath):
  35. if not subprocess.check_output(['git', 'branch', '-r', '--contains', hash]):
  36. raise Exception("The current commit is not available in the remote repository")
  37. def get_repo_and_commit_info(dirpath):
  38. with cd(dirpath):
  39. remote = subprocess.check_output(['git', 'config', '--get', 'remote.origin.url']).decode().strip()
  40. # We want the exact hash for accurate build history
  41. hash = subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode().strip()
  42. return remote, hash
  43. def koji_url(remote, hash):
  44. if remote.startswith('git@'):
  45. remote = re.sub(r'git@(.+):', r'git+https://\1/', remote)
  46. elif remote.startswith('https://'):
  47. remote = 'git+' + remote
  48. else:
  49. raise Exception("Unrecognized remote URL")
  50. return remote + "?#" + hash
  51. @contextmanager
  52. def local_branch(branch):
  53. prev_branch = subprocess.check_output(['git', 'branch', '--show-current']).strip()
  54. commit = subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode().strip()
  55. subprocess.check_call(['git', 'checkout', '--quiet', commit])
  56. try:
  57. yield branch
  58. finally:
  59. subprocess.check_call(['git', 'checkout', prev_branch])
  60. def is_old_branch(b):
  61. branch_time = datetime.strptime(b.split('/')[-1], TIME_FORMAT)
  62. return branch_time < datetime.now() - timedelta(hours=3)
  63. def clean_old_branches(git_repo):
  64. with cd(git_repo):
  65. remote_branches = [
  66. line.split()[-1] for line in subprocess.check_output(['git', 'ls-remote']).decode().splitlines()
  67. ]
  68. remote_branches = [b for b in remote_branches if b.startswith('refs/heads/koji/test/')]
  69. old_branches = [b for b in remote_branches if is_old_branch(b)]
  70. if old_branches:
  71. print("removing outdated remote branch(es)", flush=True)
  72. subprocess.check_call(['git', 'push', '--delete', 'origin'] + old_branches)
  73. def xcpng_version(target):
  74. xcpng_version_match = re.match(r'^v(\d+\.\d+)-u-\S+$', target)
  75. if xcpng_version_match is None:
  76. raise Exception(f"Can't find XCP-ng version in {target}")
  77. return xcpng_version_match.group(1)
  78. def find_next_release(package, spec, target, test_build_id, pre_build_id):
  79. assert test_build_id is not None or pre_build_id is not None
  80. builds = subprocess.check_output(['koji', 'list-builds', '--quiet', '--package', package]).decode().splitlines()
  81. if test_build_id:
  82. base_nvr = f'{package}-{spec.version}-{spec.release}.0.{test_build_id}.'
  83. else:
  84. base_nvr = f'{package}-{spec.version}-{spec.release}~{pre_build_id}.'
  85. # use a regex to match %{macro} without actually expanding the macros
  86. base_nvr_re = (
  87. re.escape(re.sub('%{.+}', "@@@", base_nvr)).replace('@@@', '.*')
  88. + r'(\d+)'
  89. + re.escape(f'.xcpng{xcpng_version(target)}')
  90. )
  91. build_matches = [re.match(base_nvr_re, b) for b in builds]
  92. build_nbs = [int(m.group(1)) for m in build_matches if m]
  93. build_nb = sorted(build_nbs)[-1] + 1 if build_nbs else 1
  94. if test_build_id:
  95. return f'{spec.release}.0.{test_build_id}.{build_nb}'
  96. else:
  97. return f'{spec.release}~{pre_build_id}.{build_nb}'
  98. def push_bumped_release(git_repo, target, test_build_id, pre_build_id):
  99. t = datetime.now().strftime(TIME_FORMAT)
  100. branch = f'koji/test/{test_build_id or pre_build_id}/{t}'
  101. with cd(git_repo), local_branch(branch):
  102. spec_paths = subprocess.check_output(['git', 'ls-files', 'SPECS/*.spec']).decode().splitlines()
  103. assert len(spec_paths) == 1
  104. spec_path = spec_paths[0]
  105. with Specfile(spec_path) as spec:
  106. # find the next build number
  107. package = Path(spec_path).stem
  108. spec.release = find_next_release(package, spec, target, test_build_id, pre_build_id)
  109. subprocess.check_call(['git', 'commit', '--quiet', '-m', "bump release for test build", spec_path])
  110. subprocess.check_call(['git', 'push', 'origin', f'HEAD:refs/heads/{branch}'])
  111. commit = subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode().strip()
  112. return commit
  113. def main():
  114. parser = argparse.ArgumentParser(
  115. description='Build a package or chain-build several from local git repos for RPM sources'
  116. )
  117. parser.add_argument('target', help='Koji target for the build')
  118. parser.add_argument('git_repos', nargs='+',
  119. help='local path to one or more git repositories. If several are provided, '
  120. 'a chained build will be started in the order of the arguments')
  121. parser.add_argument('--scratch', action="store_true", help='Perform scratch build')
  122. parser.add_argument('--nowait', action="store_true", help='Do not wait for the build to end')
  123. parser.add_argument(
  124. '--test-build',
  125. metavar="ID",
  126. help='Run a test build. The provided ID will be used to build a unique release tag.',
  127. )
  128. parser.add_argument(
  129. '--pre-build',
  130. metavar="ID",
  131. help='Run a pre build. The provided ID will be used to build a unique release tag.',
  132. )
  133. args = parser.parse_args()
  134. target = args.target
  135. git_repos = [os.path.abspath(check_dir(d)) for d in args.git_repos]
  136. is_scratch = args.scratch
  137. is_nowait = args.nowait
  138. test_build = args.test_build
  139. pre_build = args.pre_build
  140. if test_build and pre_build:
  141. logging.error("--pre-build and --test-build can't be used together")
  142. exit(1)
  143. if test_build is not None and re.match('^[a-zA-Z0-9]{1,16}$', test_build) is None:
  144. logging.error("The test build id must be 16 characters long maximum and only contain letters and digits")
  145. exit(1)
  146. if pre_build is not None and re.match('^[a-zA-Z0-9]{1,16}$', pre_build) is None:
  147. logging.error("The pre build id must be 16 characters long maximum and only contain letters and digits")
  148. exit(1)
  149. if len(git_repos) > 1 and is_scratch:
  150. parser.error("--scratch is not compatible with chained builds.")
  151. for d in git_repos:
  152. if not check_git_repo(d):
  153. parser.error("%s is not in a clean state (or is not a git repository)." % d)
  154. if len(git_repos) == 1:
  155. clean_old_branches(git_repos[0])
  156. remote, hash = get_repo_and_commit_info(git_repos[0])
  157. if test_build or pre_build:
  158. hash = push_bumped_release(git_repos[0], target, test_build, pre_build)
  159. else:
  160. check_commit_is_available_remotely(git_repos[0], hash)
  161. url = koji_url(remote, hash)
  162. command = (
  163. ['koji', 'build']
  164. + (['--scratch'] if is_scratch else [])
  165. + [target, url]
  166. + (['--nowait'] if is_nowait else [])
  167. )
  168. print(' '.join(command), flush=True)
  169. subprocess.check_call(command)
  170. else:
  171. urls = []
  172. for d in git_repos:
  173. clean_old_branches(d)
  174. remote, hash = get_repo_and_commit_info(d)
  175. if test_build or pre_build:
  176. hash = push_bumped_release(d, target, test_build, pre_build)
  177. else:
  178. check_commit_is_available_remotely(d, hash)
  179. urls.append(koji_url(remote, hash))
  180. command = ['koji', 'chain-build', target] + (' : '.join(urls)).split(' ') + (['--nowait'] if is_nowait else [])
  181. print(' '.join(command), flush=True)
  182. subprocess.check_call(command)
  183. if __name__ == "__main__":
  184. main()