release.py 15 KB

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