release.py 11 KB

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