migrate-rosdistro.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. import argparse
  2. import copy
  3. import os
  4. import os.path
  5. import shutil
  6. import subprocess
  7. import sys
  8. import tempfile
  9. from bloom.commands.git.patch.common import get_patch_config, set_patch_config
  10. from bloom.git import inbranch, show
  11. import github
  12. import yaml
  13. from rosdistro import DistributionFile, get_distribution_cache, get_distribution_file, get_index
  14. from rosdistro.writer import yaml_from_distribution_file
  15. # These functions are adapted from Bloom's internal 'get_tracks_dict_raw' and
  16. # 'write_tracks_dict_raw' functions. We cannot use them directly since they
  17. # make assumptions about the release repository that are not true during the
  18. # manipulation of the release repository for this script.
  19. def read_tracks_file():
  20. tracks_yaml = show('master', 'tracks.yaml')
  21. if tracks_yaml:
  22. return yaml.safe_load(tracks_yaml)
  23. else:
  24. raise ValueError('repository is missing tracks.yaml in master branch.')
  25. @inbranch('master')
  26. def write_tracks_file(tracks, commit_msg=None):
  27. if commit_msg is None:
  28. commit_msg = f'Update tracks.yaml from {sys.argv[0]}.'
  29. with open('tracks.yaml', 'w') as f:
  30. f.write(yaml.safe_dump(tracks, indent=2, default_flow_style=False))
  31. with open('.git/rosdistromigratecommitmsg', 'w') as f:
  32. f.write(commit_msg)
  33. subprocess.check_call(['git', 'add', 'tracks.yaml'])
  34. subprocess.check_call(['git', 'commit', '-F', '.git/rosdistromigratecommitmsg'])
  35. parser = argparse.ArgumentParser(
  36. description='Import packages from one rosdistro into another one.'
  37. )
  38. parser.add_argument('--source', required=True, help='The source rosdistro name')
  39. parser.add_argument('--source-ref', required=True, help='The git version for the source. Used to retry failed imports without bumping versions.')
  40. parser.add_argument('--dest', required=True, help='The destination rosdistro name')
  41. parser.add_argument('--release-org', required=True, help='The organization containing release repositories')
  42. args = parser.parse_args()
  43. gclient = github.Github(os.environ['GITHUB_TOKEN'])
  44. release_org = gclient.get_organization(args.release_org)
  45. org_release_repos = [r.name for r in release_org.get_repos() if r.name]
  46. if not os.path.isfile('index-v4.yaml'):
  47. raise RuntimeError('This script must be run from a rosdistro index directory.')
  48. rosdistro_dir = os.path.abspath(os.getcwd())
  49. rosdistro_index_url = f'file://{rosdistro_dir}/index-v4.yaml'
  50. index = get_index(rosdistro_index_url)
  51. index_yaml = yaml.safe_load(open('index-v4.yaml', 'r'))
  52. if len(index_yaml['distributions'][args.source]['distribution']) != 1 or \
  53. len(index_yaml['distributions'][args.dest]['distribution']) != 1:
  54. raise RuntimeError('Both source and destination distributions must have a single distribution file.')
  55. # There is a possibility that the source_ref has a different distribution file
  56. # layout. Check that they match.
  57. source_ref_index_yaml = yaml.safe_load(show(args.source_ref, 'index-v4.yaml'))
  58. if source_ref_index_yaml['distributions'][args.source]['distribution'] != \
  59. index_yaml['distributions'][args.source]['distribution']:
  60. raise RuntimeError('The distribution file layout has changed between the source ref and now.')
  61. source_distribution_filename = index_yaml['distributions'][args.source]['distribution'][0]
  62. dest_distribution_filename = index_yaml['distributions'][args.dest]['distribution'][0]
  63. # Fetch the source distribution file from the exact point in the repository history requested.
  64. source_distfile_data = yaml.safe_load(show(args.source_ref, source_distribution_filename))
  65. source_distribution = DistributionFile(args.source, source_distfile_data)
  66. # Prepare the destination distribution for new bloom releases from the source distribution.
  67. dest_distribution = get_distribution_file(index, args.dest)
  68. new_repositories = []
  69. repositories_to_retry = []
  70. for repo_name, repo_data in sorted(source_distribution.repositories.items()):
  71. if repo_name not in dest_distribution.repositories:
  72. dest_repo_data = copy.deepcopy(repo_data)
  73. if dest_repo_data.release_repository:
  74. new_repositories.append(repo_name)
  75. release_tag = dest_repo_data.release_repository.tags['release']
  76. release_tag = release_tag.replace(args.source,args.dest)
  77. dest_repo_data.release_repository.tags['release'] = release_tag
  78. dest_distribution.repositories[repo_name] = dest_repo_data
  79. elif dest_distribution.repositories[repo_name].release_repository is not None and \
  80. dest_distribution.repositories[repo_name].release_repository.version is None:
  81. dest_distribution.repositories[repo_name].release_repository.version = repo_data.release_repository.version
  82. repositories_to_retry.append(repo_name)
  83. else:
  84. # Nothing to do if the release is there.
  85. pass
  86. print(f'Found {len(new_repositories)} new repositories to release:', new_repositories)
  87. print(f'Found {len(repositories_to_retry)} repositories to retry:', repositories_to_retry)
  88. # Copy out an optimistic destination distribution file to bloom everything
  89. # against. This obviates the need to bloom packages in a topological order or
  90. # do any special handling for dependency cycles between repositories as are
  91. # known to occur in the ros2/launch repository. To allow this we must keep
  92. # track of repositories that fail to bloom and pull their release in a cleanup
  93. # step.
  94. with open(dest_distribution_filename, 'w') as f:
  95. f.write(yaml_from_distribution_file(dest_distribution))
  96. repositories_bloomed = []
  97. repositories_with_errors = []
  98. workdir = tempfile.mkdtemp()
  99. os.chdir(workdir)
  100. os.environ['ROSDISTRO_INDEX_URL'] = rosdistro_index_url
  101. os.environ['BLOOM_SKIP_ROSDEP_UPDATE'] = '1'
  102. # This call to update rosdep is critical because we're setting
  103. # ROSDISTRO_INDEX_URL above and also suppressing the automatic
  104. # update in Bloom itself.
  105. subprocess.check_call(['rosdep', 'update'])
  106. for repo_name in sorted(new_repositories + repositories_to_retry):
  107. try:
  108. release_spec = dest_distribution.repositories[repo_name].release_repository
  109. print('Adding repo:', repo_name)
  110. if release_spec.type != 'git':
  111. raise ValueError('This script can only handle git repositories.')
  112. if release_spec.version is None:
  113. raise ValueError(f'{repo_name} is not released in the source distribution (release version is missing or blank).')
  114. remote_url = release_spec.url
  115. release_repo = remote_url.split('/')[-1]
  116. if release_repo.endswith('.git'):
  117. release_repo = release_repo[:-4]
  118. subprocess.check_call(['git', 'clone', remote_url])
  119. os.chdir(release_repo)
  120. tracks = read_tracks_file()
  121. if not tracks['tracks'].get(args.source):
  122. raise ValueError('Repository has not been released.')
  123. if release_repo not in org_release_repos:
  124. release_org.create_repo(release_repo)
  125. new_release_repo_url = f'https://github.com/{args.release_org}/{release_repo}.git'
  126. subprocess.check_call(['git', 'remote', 'rename', 'origin', 'oldorigin'])
  127. subprocess.check_call(['git', 'remote', 'set-url', '--push', 'oldorigin', 'no_push'])
  128. subprocess.check_call(['git', 'remote', 'add', 'origin', new_release_repo_url])
  129. if args.source != args.dest:
  130. # Copy a bloom .ignored file from source to target distro.
  131. if os.path.isfile(f'{args.source}.ignored'):
  132. shutil.copyfile(f'{args.source}.ignored', f'{args.dest}.ignored')
  133. with open('.git/rosdistromigratecommitmsg', 'w') as f:
  134. f.write(f'Propagate {args.source} ignore file to {args.dest}.')
  135. subprocess.check_call(['git', 'add', f'{args.dest}.ignored'])
  136. subprocess.check_call(['git', 'commit', '-F', '.git/rosdistromigratecommitmsg'])
  137. # Copy the source track to the new destination.
  138. dest_track = copy.deepcopy(tracks['tracks'][args.source])
  139. dest_track['ros_distro'] = args.dest
  140. tracks['tracks'][args.dest] = dest_track
  141. ls_remote = subprocess.check_output(['git', 'ls-remote', '--heads', 'oldorigin', f'*{args.source}*'], universal_newlines=True)
  142. for line in ls_remote.split('\n'):
  143. if line == '':
  144. continue
  145. obj, ref = line.split('\t')
  146. ref = ref[11:] # strip 'refs/heads/'
  147. newref = ref.replace(args.source, args.dest)
  148. subprocess.check_call(['git', 'branch', newref, obj])
  149. if newref.startswith('patches/'):
  150. # Update parent in patch configs. Without this update the
  151. # patches will be rebased out when git-bloom-release is
  152. # called because the configured parent won't match the
  153. # expected source branch.
  154. config = get_patch_config(newref)
  155. config['parent'] = config['parent'].replace(args.source, args.dest)
  156. set_patch_config(newref, config)
  157. write_tracks_file(tracks, f'Copy {args.source} track to {args.dest} with migrate-rosdistro.py.')
  158. else:
  159. dest_track = tracks['tracks'][args.dest]
  160. # Configure next release to re-release previous version into the
  161. # destination. A version value of :{ask} will fail due to
  162. # interactivity and :{auto} may result in a previously unreleased tag
  163. # on the development branch being released for the first time.
  164. if dest_track['version'] in [':{ask}', ':{auto}']:
  165. # Override the version for this release to guarantee the same version from our
  166. # source distribution is released.
  167. dest_track['version_saved'] = dest_track['version']
  168. source_version, source_inc = source_distribution.repositories[repo_name].release_repository.version.split('-')
  169. dest_track['version'] = source_version
  170. write_tracks_file(tracks, f'Update {args.dest} track to release the same version as the source distribution.')
  171. if dest_track['release_tag'] == ':{ask}' and 'last_release' in dest_track:
  172. # Override the version for this release to guarantee the same version is released.
  173. dest_track['release_tag_saved'] = dest_track['release_tag']
  174. dest_track['release_tag'] = dest_track['last_release']
  175. write_tracks_file(tracks, f'Update {args.dest} track to release exactly last-released tag.')
  176. # Update release increment for the upcoming release.
  177. # We increment whichever is greater between the source distribution's
  178. # release increment and the release increment in the bloom track since
  179. # there may be releases that were not committed to the source
  180. # distribution.
  181. # This heuristic does not fully cover situations where the version in
  182. # the source distribution and the version in the release track differ.
  183. # In that case it is still possible for this tool to overwrite a
  184. # release increment if the greatest increment of the source version is
  185. # not in the source distribution and does not match the version
  186. # currently in the release track.
  187. release_inc = str(max(int(source_inc), int(dest_track['release_inc'])) + 1)
  188. # Bloom will not run with multiple remotes.
  189. subprocess.check_call(['git', 'remote', 'remove', 'oldorigin'])
  190. subprocess.check_call(['git', 'bloom-release', '--non-interactive', '--release-increment', release_inc, '--unsafe', args.dest], stdin=subprocess.DEVNULL, env=os.environ)
  191. subprocess.check_call(['git', 'push', 'origin', '--all', '--force'])
  192. subprocess.check_call(['git', 'push', 'origin', '--tags', '--force'])
  193. subprocess.check_call(['git', 'checkout', 'master'])
  194. # Re-read tracks.yaml after release.
  195. tracks = read_tracks_file()
  196. dest_track = tracks['tracks'][args.dest]
  197. if 'version_saved' in dest_track:
  198. dest_track['version'] = dest_track['version_saved']
  199. del dest_track['version_saved']
  200. write_tracks_file(tracks, f'Restore saved version for {args.dest} track.')
  201. if 'release_tag_saved' in dest_track:
  202. dest_track['release_tag'] = dest_track['release_tag_saved']
  203. del dest_track['release_tag_saved']
  204. write_tracks_file(tracks, f'Restore saved version and tag for {args.dest} track.')
  205. new_release_track_inc = str(int(tracks['tracks'][args.dest]['release_inc']))
  206. release_spec.url = new_release_repo_url
  207. ver, _inc = release_spec.version.split('-')
  208. release_spec.version = '-'.join([ver, new_release_track_inc])
  209. repositories_bloomed.append(repo_name)
  210. subprocess.check_call(['git', 'push', 'origin', 'master'])
  211. except (subprocess.CalledProcessError, ValueError) as e:
  212. repositories_with_errors.append((repo_name, e))
  213. os.chdir(workdir)
  214. os.chdir(rosdistro_dir)
  215. for dest_repo in sorted(new_repositories + repositories_to_retry):
  216. if dest_repo not in repositories_bloomed:
  217. print(f'{dest_repo} was not bloomed! Removing the release version,')
  218. dest_distribution.repositories[dest_repo].release_repository.version = None
  219. with open(dest_distribution_filename, 'w') as f:
  220. f.write(yaml_from_distribution_file(dest_distribution))
  221. print(f'Had {len(repositories_with_errors)} repositories with errors:', repositories_with_errors)