update-appimages.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  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, format_tag
  14. # Build matrix
  15. ARCHS = ('x86_64', 'i686', 'aarch64')
  16. MANYLINUSES = ('1', '2010', '2014', '2_24', '2_28')
  17. EXCLUDES = ('2_28_i686', '1_aarch64', '2010_aarch64')
  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 formated_tag(self):
  60. '''Returns formated manylinux tag'''
  61. return format_tag(self.tag)
  62. def previous_version(self):
  63. '''Returns previous version'''
  64. if self.asset:
  65. return self.asset.name[6:-9].split('-', 1)[0]
  66. def release_tag(self):
  67. '''Returns release git tag'''
  68. version = self.version.rsplit('.', 1)[0]
  69. return f'python{version}'
  70. def update(args):
  71. '''Update Python AppImage GitHub releases
  72. '''
  73. sha = args.sha
  74. if sha is None:
  75. sha = os.getenv('GITHUB_SHA')
  76. if sha is None:
  77. p = subprocess.run(
  78. 'git rev-parse HEAD',
  79. shell = True,
  80. capture_output = True,
  81. check = True
  82. )
  83. sha = p.stdout.decode().strip()
  84. # Connect to GitHub
  85. token = args.token
  86. if token is None:
  87. # First, check for token in env
  88. token = os.getenv('GITHUB_TOKEN')
  89. if token is None:
  90. # Else try to get a token from gh app
  91. p = subprocess.run(
  92. 'gh auth token',
  93. shell = True,
  94. capture_output = True,
  95. check = True
  96. )
  97. token = p.stdout.decode().strip()
  98. auth = Auth.Token(token)
  99. session = Github(auth=auth)
  100. repo = session.get_repo('niess/python-appimage')
  101. # Fetch currently released AppImages
  102. log('FETCH', 'currently released AppImages')
  103. releases = {}
  104. assets = defaultdict(dict)
  105. n_assets = 0
  106. for release in repo.get_releases():
  107. if release.tag_name.startswith('python'):
  108. meta = ReleaseMeta(
  109. tag = release.tag_name,
  110. release = release
  111. )
  112. ref = repo.get_git_ref(f'tags/{meta.tag}')
  113. if (ref.ref is not None) and (ref.object.sha != sha):
  114. meta.ref = ref
  115. releases[release.tag_name] = meta
  116. for asset in release.get_assets():
  117. if asset.name.endswith('.AppImage'):
  118. n_assets += 1
  119. meta = AssetMeta.from_appimage(asset.name)
  120. assert(meta.release_tag() == release.tag_name)
  121. meta.asset = asset
  122. assets[meta.tag][meta.abi] = meta
  123. n_releases = len(releases)
  124. log('FETCH', f'found {n_assets} AppImages in {n_releases} releases')
  125. # Look for updates.
  126. new_releases = set()
  127. new_assets = []
  128. for manylinux in MANYLINUSES:
  129. for arch in ARCHS:
  130. tag = f'{manylinux}_{arch}'
  131. if tag in EXCLUDES:
  132. continue
  133. pythons = list_pythons(tag)
  134. for (abi, version) in pythons:
  135. try:
  136. meta = assets[tag][abi]
  137. except KeyError:
  138. meta = None
  139. if (meta is None) or (meta.version != version) or args.all:
  140. new_meta = AssetMeta(
  141. tag = tag,
  142. abi = abi,
  143. version = version
  144. )
  145. if meta is not None:
  146. new_meta.asset = meta.asset
  147. new_assets.append(new_meta)
  148. rtag = new_meta.release_tag()
  149. if rtag not in releases:
  150. new_releases.add(rtag)
  151. if args.dry:
  152. # Log foreseen changes and exit
  153. for tag in new_releases:
  154. meta = ReleaseMeta(tag)
  155. log('DRY', f'new release for {meta.title()}')
  156. for meta in new_assets:
  157. log('DRY', f'create asset {meta.appimage_name()}')
  158. if meta.asset is not None:
  159. log('DRY', f'remove asset {meta.asset.name}')
  160. for meta in releases.values():
  161. if meta.ref is not None:
  162. log('DRY', f'refs/tags/{meta.tag} -> {sha}')
  163. if meta.release is not None:
  164. log('DRY', f'reformat release for {meta.title()}')
  165. if new_assets:
  166. log('DRY', f'new update summary with {len(new_assets)} entries')
  167. if not args.build:
  168. return
  169. if new_assets:
  170. # Build new AppImage(s)
  171. cwd = os.getcwd()
  172. os.makedirs(APPIMAGES_DIR, exist_ok=True)
  173. try:
  174. os.chdir(APPIMAGES_DIR)
  175. for meta in new_assets:
  176. build_manylinux(meta.tag, meta.abi)
  177. finally:
  178. os.chdir(cwd)
  179. if args.dry:
  180. return
  181. # Create any new release(s).
  182. for tag in new_releases:
  183. meta = ReleaseMeta(tag)
  184. title = meta.title()
  185. meta.release = repo.create_git_release(
  186. tag = meta.tag,
  187. name = title,
  188. message = meta.message(),
  189. prerelease = True
  190. )
  191. releases[tag] = meta
  192. log('UPDATE', f'new release for {title}')
  193. # Update assets.
  194. update_summary = []
  195. for meta in new_assets:
  196. release = releases[meta.release_tag()].release
  197. appimage = meta.appimage_name()
  198. if meta.asset and (meta.asset.name == appimage):
  199. meta.asset.delete_asset()
  200. update_summary.append(
  201. f'- update {meta.formated_tag()}/{meta.abi} {meta.version}'
  202. )
  203. new_asset = release.upload_asset(
  204. path = f'{APPIMAGES_DIR}/{appimage}',
  205. name = appimage
  206. )
  207. else:
  208. new_asset = release.upload_asset(
  209. path = f'{APPIMAGES_DIR}/{appimage}',
  210. name = appimage
  211. )
  212. if meta.asset:
  213. meta.asset.delete_asset()
  214. update_summary.append(
  215. f'- update {meta.formated_tag()}/{meta.abi} '
  216. f'{meta.previous_version()} -> {meta.version}'
  217. )
  218. else:
  219. update_summary.append(
  220. f'- add {meta.formated_tag()}/{meta.abi} {meta.version}'
  221. )
  222. meta.asset = new_asset
  223. assets[meta.tag][meta.abi] = meta
  224. # Update git tags SHA
  225. for meta in releases.values():
  226. if meta.ref is not None:
  227. meta.ref.edit(
  228. sha = sha,
  229. force = True
  230. )
  231. log('UPDATE', f'refs/tags/{meta.tag} -> {sha}')
  232. if meta.release is not None:
  233. title = meta.title()
  234. meta.release.update_release(
  235. name = title,
  236. message = meta.message(),
  237. prerelease = True,
  238. tag_name = meta.tag
  239. )
  240. log('UPDATE', f'reformat release for {title}')
  241. # Generate update summary
  242. if update_summary:
  243. for release in repo.get_releases():
  244. if release.tag_name == 'update-summary':
  245. release.delete_release()
  246. break
  247. message = os.linesep.join(update_summary)
  248. repo.create_git_release(
  249. tag = 'update-summary',
  250. name = 'Update summary',
  251. message = message,
  252. prerelease = True
  253. )
  254. if __name__ == '__main__':
  255. parser = argparse.ArgumentParser(
  256. description = 'Update GitHub releases of Python AppImages'
  257. )
  258. parser.add_argument('-a', '--all',
  259. help = 'force update of all available releases',
  260. action = 'store_true',
  261. default = False
  262. )
  263. parser.add_argument('-b', '--build',
  264. help = 'build AppImages (in dry mode)',
  265. action = 'store_true',
  266. default = False
  267. )
  268. parser.add_argument('-d', '--dry',
  269. help = 'dry run (only log changes)',
  270. action = 'store_true',
  271. default = False
  272. )
  273. parser.add_argument('-m', '--manylinux',
  274. help = 'target specific manylinux tags',
  275. nargs = "+"
  276. )
  277. parser.add_argument("-s", "--sha",
  278. help = "reference commit SHA"
  279. )
  280. parser.add_argument('-t', '--token',
  281. help = 'GitHub authentication token'
  282. )
  283. args = parser.parse_args()
  284. if args.manylinux:
  285. MANYLINUSES = args.manylinux
  286. sys.argv = sys.argv[:1] # Empty args for fake call
  287. update(args)