release.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. from __future__ import absolute_import
  2. from __future__ import print_function
  3. from __future__ import unicode_literals
  4. import argparse
  5. import os
  6. import sys
  7. import time
  8. from distutils.core import run_setup
  9. import pypandoc
  10. from jinja2 import Template
  11. from release.bintray import BintrayAPI
  12. from release.const import BINTRAY_ORG
  13. from release.const import NAME
  14. from release.const import REPO_ROOT
  15. from release.downloader import BinaryDownloader
  16. from release.images import ImageManager
  17. from release.repository import delete_assets
  18. from release.repository import get_contributors
  19. from release.repository import Repository
  20. from release.repository import upload_assets
  21. from release.utils import branch_name
  22. from release.utils import compatibility_matrix
  23. from release.utils import read_release_notes_from_changelog
  24. from release.utils import ScriptError
  25. from release.utils import update_init_py_version
  26. from release.utils import update_run_sh_version
  27. from release.utils import yesno
  28. from twine.commands.upload import main as twine_upload
  29. def create_initial_branch(repository, args):
  30. release_branch = repository.create_release_branch(args.release, args.base)
  31. if args.base and args.cherries:
  32. print('Detected patch version.')
  33. auto_prs = repository.get_prs_in_milestone(args.release)
  34. if auto_prs:
  35. print(
  36. 'Found the following PRs in this release\'s milestone: {}'.format(', '.join(auto_prs))
  37. )
  38. proceed = yesno('Automatically cherry-pick detected PRs? Y/n', default=True)
  39. if proceed:
  40. repository.cherry_pick_prs(release_branch, auto_prs)
  41. cherries = input('Indicate (space-separated) PR numbers to cherry-pick then press Enter:\n')
  42. repository.cherry_pick_prs(release_branch, cherries.split())
  43. return create_bump_commit(repository, release_branch, args.bintray_user, args.bintray_org)
  44. def create_bump_commit(repository, release_branch, bintray_user, bintray_org):
  45. with release_branch.config_reader() as cfg:
  46. release = cfg.get('release')
  47. print('Updating version info in __init__.py and run.sh')
  48. update_run_sh_version(release)
  49. update_init_py_version(release)
  50. input('Please add the release notes to the CHANGELOG.md file, then press Enter to continue.')
  51. proceed = None
  52. while not proceed:
  53. print(repository.diff())
  54. proceed = yesno('Are these changes ok? y/N ', default=False)
  55. if repository.diff():
  56. repository.create_bump_commit(release_branch, release)
  57. repository.push_branch_to_remote(release_branch)
  58. bintray_api = BintrayAPI(os.environ['BINTRAY_TOKEN'], bintray_user)
  59. print('Creating data repository {} on bintray'.format(release_branch.name))
  60. bintray_api.create_repository(bintray_org, release_branch.name, 'generic')
  61. def monitor_pr_status(pr_data):
  62. print('Waiting for CI to complete...')
  63. last_commit = pr_data.get_commits().reversed[0]
  64. while True:
  65. status = last_commit.get_combined_status()
  66. if status.state == 'pending' or status.state == 'failure':
  67. summary = {
  68. 'pending': 0,
  69. 'success': 0,
  70. 'failure': 0,
  71. }
  72. for detail in status.statuses:
  73. if detail.context == 'dco-signed':
  74. # dco-signed check breaks on merge remote-tracking ; ignore it
  75. continue
  76. summary[detail.state] += 1
  77. print('{pending} pending, {success} successes, {failure} failures'.format(**summary))
  78. if summary['pending'] == 0 and summary['failure'] == 0 and summary['success'] > 0:
  79. # This check assumes at least 1 non-DCO CI check to avoid race conditions.
  80. # If testing on a repo without CI, use --skip-ci-check to avoid looping eternally
  81. return True
  82. elif summary['failure'] > 0:
  83. raise ScriptError('CI failures detected!')
  84. time.sleep(30)
  85. elif status.state == 'success':
  86. print('{} successes: all clear!'.format(status.total_count))
  87. return True
  88. def check_pr_mergeable(pr_data):
  89. if not pr_data.mergeable:
  90. print(
  91. 'WARNING!! PR #{} can not currently be merged. You will need to '
  92. 'resolve the conflicts manually before finalizing the release.'.format(pr_data.number)
  93. )
  94. return pr_data.mergeable
  95. def create_release_draft(repository, version, pr_data, files):
  96. print('Creating Github release draft')
  97. with open(os.path.join(os.path.dirname(__file__), 'release.md.tmpl'), 'r') as f:
  98. template = Template(f.read())
  99. print('Rendering release notes based on template')
  100. release_notes = template.render(
  101. version=version,
  102. compat_matrix=compatibility_matrix(),
  103. integrity=files,
  104. contributors=get_contributors(pr_data),
  105. changelog=read_release_notes_from_changelog(),
  106. )
  107. gh_release = repository.create_release(
  108. version, release_notes, draft=True, prerelease='-rc' in version,
  109. target_commitish='release'
  110. )
  111. print('Release draft initialized')
  112. return gh_release
  113. def print_final_instructions(args):
  114. print(
  115. "You're almost done! Please verify that everything is in order and "
  116. "you are ready to make the release public, then run the following "
  117. "command:\n{exe} -b {user} finalize {version}".format(
  118. exe=sys.argv[0], user=args.bintray_user, version=args.release
  119. )
  120. )
  121. def resume(args):
  122. try:
  123. repository = Repository(REPO_ROOT, args.repo)
  124. br_name = branch_name(args.release)
  125. if not repository.branch_exists(br_name):
  126. raise ScriptError('No local branch exists for this release.')
  127. gh_release = repository.find_release(args.release)
  128. if gh_release and not gh_release.draft:
  129. print('WARNING!! Found non-draft (public) release for this version!')
  130. proceed = yesno(
  131. 'Are you sure you wish to proceed? Modifying an already '
  132. 'released version is dangerous! y/N ', default=False
  133. )
  134. if proceed.lower() is not True:
  135. raise ScriptError('Aborting release')
  136. release_branch = repository.checkout_branch(br_name)
  137. if args.cherries:
  138. cherries = input('Indicate (space-separated) PR numbers to cherry-pick then press Enter:\n')
  139. repository.cherry_pick_prs(release_branch, cherries.split())
  140. create_bump_commit(repository, release_branch, args.bintray_user, args.bintray_org)
  141. pr_data = repository.find_release_pr(args.release)
  142. if not pr_data:
  143. pr_data = repository.create_release_pull_request(args.release)
  144. check_pr_mergeable(pr_data)
  145. if not args.skip_ci:
  146. monitor_pr_status(pr_data)
  147. downloader = BinaryDownloader(args.destination)
  148. files = downloader.download_all(args.release)
  149. if not gh_release:
  150. gh_release = create_release_draft(repository, args.release, pr_data, files)
  151. delete_assets(gh_release)
  152. upload_assets(gh_release, files)
  153. img_manager = ImageManager(args.release)
  154. img_manager.build_images(repository, files)
  155. except ScriptError as e:
  156. print(e)
  157. return 1
  158. print_final_instructions(args)
  159. return 0
  160. def cancel(args):
  161. try:
  162. repository = Repository(REPO_ROOT, args.repo)
  163. repository.close_release_pr(args.release)
  164. repository.remove_release(args.release)
  165. repository.remove_bump_branch(args.release)
  166. bintray_api = BintrayAPI(os.environ['BINTRAY_TOKEN'], args.bintray_user)
  167. print('Removing Bintray data repository for {}'.format(args.release))
  168. bintray_api.delete_repository(args.bintray_org, branch_name(args.release))
  169. except ScriptError as e:
  170. print(e)
  171. return 1
  172. print('Release cancellation complete.')
  173. return 0
  174. def start(args):
  175. try:
  176. repository = Repository(REPO_ROOT, args.repo)
  177. create_initial_branch(repository, args)
  178. pr_data = repository.create_release_pull_request(args.release)
  179. check_pr_mergeable(pr_data)
  180. if not args.skip_ci:
  181. monitor_pr_status(pr_data)
  182. downloader = BinaryDownloader(args.destination)
  183. files = downloader.download_all(args.release)
  184. gh_release = create_release_draft(repository, args.release, pr_data, files)
  185. upload_assets(gh_release, files)
  186. img_manager = ImageManager(args.release)
  187. img_manager.build_images(repository, files)
  188. except ScriptError as e:
  189. print(e)
  190. return 1
  191. print_final_instructions(args)
  192. return 0
  193. def finalize(args):
  194. try:
  195. repository = Repository(REPO_ROOT, args.repo)
  196. img_manager = ImageManager(args.release)
  197. pr_data = repository.find_release_pr(args.release)
  198. if not pr_data:
  199. raise ScriptError('No PR found for {}'.format(args.release))
  200. if not check_pr_mergeable(pr_data):
  201. raise ScriptError('Can not finalize release with an unmergeable PR')
  202. if not img_manager.check_images(args.release):
  203. raise ScriptError('Missing release image')
  204. br_name = branch_name(args.release)
  205. if not repository.branch_exists(br_name):
  206. raise ScriptError('No local branch exists for this release.')
  207. gh_release = repository.find_release(args.release)
  208. if not gh_release:
  209. raise ScriptError('No Github release draft for this version')
  210. repository.checkout_branch(br_name)
  211. pypandoc.convert_file(
  212. os.path.join(REPO_ROOT, 'README.md'), 'rst', outputfile=os.path.join(REPO_ROOT, 'README.rst')
  213. )
  214. run_setup(os.path.join(REPO_ROOT, 'setup.py'), script_args=['sdist', 'bdist_wheel'])
  215. merge_status = pr_data.merge()
  216. if not merge_status.merged:
  217. raise ScriptError('Unable to merge PR #{}: {}'.format(pr_data.number, merge_status.message))
  218. print('Uploading to PyPi')
  219. twine_upload(['dist/*'])
  220. img_manager.push_images()
  221. repository.publish_release(gh_release)
  222. except ScriptError as e:
  223. print(e)
  224. return 1
  225. return 0
  226. ACTIONS = [
  227. 'start',
  228. 'cancel',
  229. 'resume',
  230. 'finalize',
  231. ]
  232. EPILOG = '''Example uses:
  233. * Start a new feature release (includes all changes currently in master)
  234. release.py -b user start 1.23.0
  235. * Start a new patch release
  236. release.py -b user --patch 1.21.0 start 1.21.1
  237. * Cancel / rollback an existing release draft
  238. release.py -b user cancel 1.23.0
  239. * Restart a previously aborted patch release
  240. release.py -b user -p 1.21.0 resume 1.21.1
  241. '''
  242. def main():
  243. if 'GITHUB_TOKEN' not in os.environ:
  244. print('GITHUB_TOKEN environment variable must be set')
  245. return 1
  246. if 'BINTRAY_TOKEN' not in os.environ:
  247. print('BINTRAY_TOKEN environment variable must be set')
  248. return 1
  249. parser = argparse.ArgumentParser(
  250. description='Orchestrate a new release of docker/compose. This tool assumes that you have '
  251. 'obtained a Github API token and Bintray API key and set the GITHUB_TOKEN and '
  252. 'BINTRAY_TOKEN environment variables accordingly.',
  253. epilog=EPILOG, formatter_class=argparse.RawTextHelpFormatter)
  254. parser.add_argument(
  255. 'action', choices=ACTIONS, help='The action to be performed for this release'
  256. )
  257. parser.add_argument('release', help='Release number, e.g. 1.9.0-rc1, 2.1.1')
  258. parser.add_argument(
  259. '--patch', '-p', dest='base',
  260. help='Which version is being patched by this release'
  261. )
  262. parser.add_argument(
  263. '--repo', '-r', dest='repo', default=NAME,
  264. help='Start a release for the given repo (default: {})'.format(NAME)
  265. )
  266. parser.add_argument(
  267. '-b', dest='bintray_user', required=True, metavar='USER',
  268. help='Username associated with the Bintray API key'
  269. )
  270. parser.add_argument(
  271. '--bintray-org', dest='bintray_org', metavar='ORG', default=BINTRAY_ORG,
  272. help='Organization name on bintray where the data repository will be created.'
  273. )
  274. parser.add_argument(
  275. '--destination', '-o', metavar='DIR', default='binaries',
  276. help='Directory where release binaries will be downloaded relative to the project root'
  277. )
  278. parser.add_argument(
  279. '--no-cherries', '-C', dest='cherries', action='store_false',
  280. help='If set, the program will not prompt the user for PR numbers to cherry-pick'
  281. )
  282. parser.add_argument(
  283. '--skip-ci-checks', dest='skip_ci', action='store_true',
  284. help='If set, the program will not wait for CI jobs to complete'
  285. )
  286. args = parser.parse_args()
  287. if args.action == 'start':
  288. return start(args)
  289. elif args.action == 'resume':
  290. return resume(args)
  291. elif args.action == 'cancel':
  292. return cancel(args)
  293. elif args.action == 'finalize':
  294. return finalize(args)
  295. print('Unexpected action "{}"'.format(args.action), file=sys.stderr)
  296. return 1
  297. if __name__ == '__main__':
  298. sys.exit(main())