update-appimages.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. #! /usr/bin/env python3
  2. import argparse
  3. from collections import defaultdict
  4. from dataclasses import dataclass
  5. import os
  6. import subprocess
  7. import sys
  8. from typing import Optional
  9. from github import Auth, Github
  10. from python_appimage.commands.build.manylinux import execute as build_manylinux
  11. from python_appimage.commands.list import execute as list_pythons
  12. from python_appimage.utils.log import log
  13. from python_appimage.utils.manylinux import format_appimage_name
  14. # Build matrix
  15. ARCHS = ('x86_64', 'i686')
  16. MANYLINUSES = ('1', '2010', '2014', '2_24', '2_28')
  17. EXCLUDES = ('2_28_i686',)
  18. # Build directory for AppImages
  19. APPIMAGES_DIR = 'build-appimages'
  20. @dataclass
  21. class ReleaseMeta:
  22. '''Metadata relative to a GitHub release
  23. '''
  24. tag: str
  25. ref: Optional["github.GitRef"] = None
  26. release: Optional["github.GitRelease"] = None
  27. def message(self):
  28. '''Returns release message'''
  29. return f'Appimage distributions of {self.title()} (see `Assets` below)'
  30. def title(self):
  31. '''Returns release title'''
  32. version = self.tag[6:]
  33. return f'Python {version}'
  34. @dataclass
  35. class AssetMeta:
  36. '''Metadata relative to a release Asset
  37. '''
  38. tag: str
  39. abi: str
  40. version: str
  41. asset: Optional["github.GitReleaseAsset"] = None
  42. @classmethod
  43. def from_appimage(cls, name):
  44. '''Returns an instance from a Python AppImage name
  45. '''
  46. tmp = name[6:-9]
  47. tmp, tag = tmp.split('-manylinux', 1)
  48. if tag.startswith('_'):
  49. tag = tag[1:]
  50. version, abi = tmp.split('-', 1)
  51. return cls(
  52. tag = tag,
  53. abi = abi,
  54. version = version
  55. )
  56. def appimage_name(self):
  57. '''Returns Python AppImage name'''
  58. return format_appimage_name(self.abi, self.version, self.tag)
  59. def release_tag(self):
  60. '''Returns release git tag'''
  61. version = self.version.rsplit('.', 1)[0]
  62. return f'python{version}'
  63. def update(args):
  64. '''Update Python AppImage GitHub releases
  65. '''
  66. sha = args.sha
  67. if sha is None:
  68. sha = os.getenv('GITHUB_SHA')
  69. if sha is None:
  70. p = subprocess.run(
  71. 'git rev-parse HEAD',
  72. shell = True,
  73. capture_output = True,
  74. check = True
  75. )
  76. sha = p.stdout.decode().strip()
  77. # Connect to GitHub
  78. token = args.token
  79. if token is None:
  80. # First, check for token in env
  81. token = os.getenv('GITHUB_TOKEN')
  82. if token is None:
  83. # Else try to get a token from gh app
  84. p = subprocess.run(
  85. 'gh auth token',
  86. shell = True,
  87. capture_output = True,
  88. check = True
  89. )
  90. token = p.stdout.decode().strip()
  91. auth = Auth.Token(token)
  92. session = Github(auth=auth)
  93. repo = session.get_repo('niess/python-appimage')
  94. # Fetch currently released AppImages
  95. log('FETCH', 'currently released AppImages')
  96. releases = {}
  97. assets = defaultdict(dict)
  98. n_assets = 0
  99. for release in repo.get_releases():
  100. if release.tag_name.startswith('python'):
  101. meta = ReleaseMeta(
  102. tag = release.tag_name,
  103. release = release
  104. )
  105. ref = repo.get_git_ref(f'tags/{meta.tag}')
  106. if (ref.ref is not None) and (ref.object.sha != sha):
  107. meta.ref = ref
  108. releases[release.tag_name] = meta
  109. for asset in release.get_assets():
  110. if asset.name.endswith('.AppImage'):
  111. n_assets += 1
  112. meta = AssetMeta.from_appimage(asset.name)
  113. assert(meta.release_tag() == release.tag_name)
  114. meta.asset = asset
  115. assets[meta.tag][meta.abi] = meta
  116. n_releases = len(releases)
  117. log('FETCH', f'found {n_assets} AppImages in {n_releases} releases')
  118. # Look for updates.
  119. new_releases = set()
  120. new_assets = []
  121. for manylinux in MANYLINUSES:
  122. for arch in ARCHS:
  123. tag = f'{manylinux}_{arch}'
  124. if tag in EXCLUDES:
  125. continue
  126. pythons = list_pythons(tag)
  127. for (abi, version) in pythons:
  128. try:
  129. meta = assets[tag][abi]
  130. except KeyError:
  131. meta = None
  132. if (meta is None) or (meta.version != version) or args.all:
  133. new_meta = AssetMeta(
  134. tag = tag,
  135. abi = abi,
  136. version = version
  137. )
  138. if meta is not None:
  139. new_meta.asset = meta.asset
  140. new_assets.append(new_meta)
  141. rtag = new_meta.release_tag()
  142. if rtag not in releases:
  143. new_releases.add(rtag)
  144. if args.dry:
  145. # Log foreseen changes and exit
  146. for tag in new_releases:
  147. meta = ReleaseMeta(tag)
  148. log('DRY', f'new release for {meta.title()}')
  149. for meta in new_assets:
  150. log('DRY', f'create asset {meta.appimage_name()}')
  151. if meta.asset is not None:
  152. log('DRY', f'remove asset {meta.asset.name}')
  153. for meta in releases.values():
  154. if meta.ref is not None:
  155. log('DRY', f'refs/tags/{meta.tag} -> {sha}')
  156. if meta.release is not None:
  157. log('DRY', f'reformat release for {meta.title()}')
  158. return
  159. if new_assets:
  160. # Build new AppImage(s)
  161. cwd = os.getcwd()
  162. os.makedirs(APPIMAGES_DIR, exist_ok=True)
  163. try:
  164. os.chdir(APPIMAGES_DIR)
  165. for meta in new_assets:
  166. build_manylinux(meta.tag, meta.abi)
  167. finally:
  168. os.chdir(cwd)
  169. # Create any new release(s).
  170. for tag in new_releases:
  171. meta = ReleaseMeta(tag)
  172. title = meta.title()
  173. meta.release = repo.create_git_release(
  174. tag = meta.tag,
  175. name = title,
  176. message = meta.message(),
  177. prerelease = True
  178. )
  179. releases[tag] = meta
  180. log('UPDATE', f'new release for {title}')
  181. # Update assets.
  182. for meta in new_assets:
  183. release = releases[meta.release_tag()].release
  184. appimage = meta.appimage_name()
  185. new_asset = release.upload_asset(
  186. path = f'{APPIMAGES_DIR}/{appimage}',
  187. name = appimage
  188. )
  189. if meta.asset:
  190. meta.asset.delete_asset()
  191. meta.asset = new_asset
  192. assets[meta.tag][meta.abi] = meta
  193. # Update git tags SHA.
  194. for meta in releases.values():
  195. if meta.ref is not None:
  196. meta.ref.edit(
  197. sha = sha,
  198. force = True
  199. )
  200. log('UPDATE', f'refs/tags/{meta.tag} -> {sha}')
  201. if meta.release is not None:
  202. title = meta.title()
  203. meta.release.update_release(
  204. name = title,
  205. message = meta.message(),
  206. prerelease = True,
  207. tag_name = meta.tag
  208. )
  209. log('UPDATE', f'reformat release for {title}')
  210. if __name__ == '__main__':
  211. parser = argparse.ArgumentParser(
  212. description = 'Update GitHub releases of Python AppImages'
  213. )
  214. parser.add_argument('-a', '--all',
  215. help = 'force update of all available releases',
  216. action = 'store_true',
  217. default = False
  218. )
  219. parser.add_argument('-d', '--dry',
  220. help = 'dry run (only log changes)',
  221. action = 'store_true',
  222. default = False
  223. )
  224. parser.add_argument("-s", "--sha",
  225. help = "reference commit SHA"
  226. )
  227. parser.add_argument('-t', '--token',
  228. help = 'GitHub authentication token'
  229. )
  230. args = parser.parse_args()
  231. sys.argv = sys.argv[:1] # Empty args for fake call
  232. update(args)