release.py 11 KB

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