github_release.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. #!/usr/bin/python3
  2. """
  3. Creates Github Releases and uploads assets
  4. """
  5. import argparse
  6. import logging
  7. import os
  8. import shutil
  9. import hashlib
  10. import requests
  11. import tarfile
  12. from os import listdir
  13. from os.path import isfile, join, splitext
  14. import re
  15. import subprocess
  16. from github import Github, GithubException, UnknownObjectException
  17. FORMAT = "%(levelname)s - %(asctime)s: %(message)s"
  18. logging.basicConfig(format=FORMAT, level=logging.INFO)
  19. CLOUDFLARED_REPO = os.environ.get("GITHUB_REPO", "cloudflare/cloudflared")
  20. GITHUB_CONFLICT_CODE = "already_exists"
  21. BASE_KV_URL = 'https://api.cloudflare.com/client/v4/accounts/'
  22. UPDATER_PREFIX = 'update'
  23. def get_sha256(filename):
  24. """ get the sha256 of a file """
  25. sha256_hash = hashlib.sha256()
  26. with open(filename,"rb") as f:
  27. for byte_block in iter(lambda: f.read(4096),b""):
  28. sha256_hash.update(byte_block)
  29. return sha256_hash.hexdigest()
  30. def send_hash(pkg_hash, name, version, account, namespace, api_token):
  31. """ send the checksum of a file to workers kv """
  32. key = '{0}_{1}_{2}'.format(UPDATER_PREFIX, version, name)
  33. headers = {
  34. "Content-Type": "application/json",
  35. "Authorization": "Bearer " + api_token,
  36. }
  37. response = requests.put(
  38. BASE_KV_URL + account + "/storage/kv/namespaces/" + namespace + "/values/" + key,
  39. headers=headers,
  40. data=pkg_hash
  41. )
  42. if response.status_code != 200:
  43. jsonResponse = response.json()
  44. errors = jsonResponse["errors"]
  45. if len(errors) > 0:
  46. raise Exception("failed to upload checksum: {0}", errors[0])
  47. def assert_tag_exists(repo, version):
  48. """ Raise exception if repo does not contain a tag matching version """
  49. tags = repo.get_tags()
  50. for tag in tags:
  51. print(tag)
  52. print(tag.name)
  53. if not tags or tags[0].name != version:
  54. raise Exception("Tag {} not found".format(version))
  55. def get_or_create_release(repo, version, dry_run=False, is_draft=False):
  56. """
  57. Get a Github Release matching the version tag or create a new one.
  58. If a conflict occurs on creation, attempt to fetch the Release on last time
  59. """
  60. try:
  61. release = repo.get_release(version)
  62. logging.info("Release %s found", version)
  63. return release
  64. except UnknownObjectException:
  65. logging.info("Release %s not found", version)
  66. # We don't want to create a new release tag if one doesn't already exist
  67. assert_tag_exists(repo, version)
  68. if dry_run:
  69. logging.info("Skipping Release creation because of dry-run")
  70. return
  71. try:
  72. if is_draft:
  73. logging.info("Drafting release %s", version)
  74. else:
  75. logging.info("Creating release %s", version)
  76. return repo.create_git_release(version, version, "", is_draft)
  77. except GithubException as e:
  78. errors = e.data.get("errors", [])
  79. if e.status == 422 and any(
  80. [err.get("code") == GITHUB_CONFLICT_CODE for err in errors]
  81. ):
  82. logging.warning(
  83. "Conflict: Release was likely just made by a different build: %s",
  84. e.data,
  85. )
  86. return repo.get_release(version)
  87. raise e
  88. def parse_args():
  89. """ Parse and validate args """
  90. parser = argparse.ArgumentParser(
  91. description="Creates Github Releases and uploads assets."
  92. )
  93. parser.add_argument(
  94. "--api-key", default=os.environ.get("API_KEY"), help="Github API key"
  95. )
  96. parser.add_argument(
  97. "--release-version",
  98. metavar="version",
  99. default=os.environ.get("VERSION"),
  100. help="Release version",
  101. )
  102. parser.add_argument(
  103. "--path", default=os.environ.get("ASSET_PATH"), help="Asset path"
  104. )
  105. parser.add_argument(
  106. "--name", default=os.environ.get("ASSET_NAME"), help="Asset Name"
  107. )
  108. parser.add_argument(
  109. "--namespace-id", default=os.environ.get("KV_NAMESPACE"), help="workersKV namespace id"
  110. )
  111. parser.add_argument(
  112. "--kv-account-id", default=os.environ.get("KV_ACCOUNT"), help="workersKV account id"
  113. )
  114. parser.add_argument(
  115. "--kv-api-token", default=os.environ.get("KV_API_TOKEN"), help="workersKV API Token"
  116. )
  117. parser.add_argument(
  118. "--dry-run", action="store_true", help="Do not create release or upload asset"
  119. )
  120. parser.add_argument(
  121. "--draft", action="store_true", help="Create a draft release"
  122. )
  123. args = parser.parse_args()
  124. is_valid = True
  125. if not args.release_version:
  126. logging.error("Missing release version")
  127. is_valid = False
  128. if not args.path:
  129. logging.error("Missing asset path")
  130. is_valid = False
  131. if not args.name and not os.path.isdir(args.path):
  132. logging.error("Missing asset name")
  133. is_valid = False
  134. if not args.api_key:
  135. logging.error("Missing API key")
  136. is_valid = False
  137. if not args.namespace_id:
  138. logging.error("Missing KV namespace id")
  139. is_valid = False
  140. if not args.kv_account_id:
  141. logging.error("Missing KV account id")
  142. is_valid = False
  143. if not args.kv_api_token:
  144. logging.error("Missing KV API token")
  145. is_valid = False
  146. if is_valid:
  147. return args
  148. parser.print_usage()
  149. exit(1)
  150. def upload_asset(release, filepath, filename, release_version, kv_account_id, namespace_id, kv_api_token):
  151. logging.info("Uploading asset: %s", filename)
  152. assets = release.get_assets()
  153. uploaded = False
  154. for asset in assets:
  155. if asset.name == filename:
  156. uploaded = True
  157. break
  158. if uploaded:
  159. logging.info("asset already uploaded, skipping upload")
  160. return
  161. release.upload_asset(filepath, name=filename)
  162. # check and extract if the file is a tar and gzipped file (as is the case with the macos builds)
  163. binary_path = filepath
  164. if binary_path.endswith("tgz"):
  165. try:
  166. shutil.rmtree('cfd')
  167. except OSError:
  168. pass
  169. zipfile = tarfile.open(binary_path, "r:gz")
  170. zipfile.extractall('cfd') # specify which folder to extract to
  171. zipfile.close()
  172. binary_path = os.path.join(os.getcwd(), 'cfd', 'cloudflared')
  173. # send the sha256 (the checksum) to workers kv
  174. # logging.info("Uploading sha256 checksum for: %s", filename)
  175. # pkg_hash = get_sha256(binary_path)
  176. #send_hash(pkg_hash, filename, release_version, kv_account_id, namespace_id, kv_api_token)
  177. def move_asset(filepath, filename):
  178. # create the artifacts directory if it doesn't exist
  179. artifact_path = os.path.join(os.getcwd(), 'artifacts')
  180. if not os.path.isdir(artifact_path):
  181. os.mkdir(artifact_path)
  182. # copy the binary to the path
  183. copy_path = os.path.join(artifact_path, filename)
  184. try:
  185. shutil.copy(filepath, copy_path)
  186. except shutil.SameFileError:
  187. pass # the macOS release copy fails with being the same file (already in the artifacts directory)
  188. def get_binary_version(binary_path):
  189. """
  190. Sample output from go version -m <binary>:
  191. ...
  192. build -compiler=gc
  193. build -ldflags="-X \"main.Version=2024.8.3-6-gec072691\" -X \"main.BuildTime=2024-09-10-1027 UTC\" "
  194. build CGO_ENABLED=1
  195. ...
  196. This function parses the above output to retrieve the following substring 2024.8.3-6-gec072691.
  197. To do this a start and end indexes are computed and the a slice is extracted from the output using them.
  198. """
  199. needle = "main.Version="
  200. cmd = ['go','version', '-m', binary_path]
  201. process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  202. output, _ = process.communicate()
  203. version_info = output.decode()
  204. # Find start of needle
  205. needle_index = version_info.find(needle)
  206. # Find backward slash relative to the beggining of the needle
  207. relative_end_index = version_info[needle_index:].find("\\")
  208. # Calculate needle position plus needle length to find version beggining
  209. start_index = needle_index + len(needle)
  210. # Calculate needle position plus relative position of the backward slash
  211. end_index = needle_index + relative_end_index
  212. return version_info[start_index:end_index]
  213. def assert_asset_version(binary_path, release_version):
  214. """
  215. Asserts that the artifacts have the correct release_version.
  216. The artifacts that are checked must not have an extension expecting .exe and .tgz.
  217. In the occurrence of any other extension the function exits early.
  218. """
  219. try:
  220. shutil.rmtree('tmp')
  221. except OSError:
  222. pass
  223. _, ext = os.path.splitext(binary_path)
  224. if ext == '.exe' or ext == '':
  225. binary_version = get_binary_version(binary_path)
  226. elif ext == '.tgz':
  227. tar = tarfile.open(binary_path, "r:gz")
  228. tar.extractall("tmp")
  229. tar.close()
  230. binary_path = os.path.join(os.getcwd(), 'tmp', 'cloudflared')
  231. binary_version = get_binary_version(binary_path)
  232. else:
  233. return
  234. if binary_version != release_version:
  235. logging.error(f"Version mismatch {binary_path}, binary_version {binary_version} release_version {release_version}")
  236. exit(1)
  237. def main():
  238. """ Attempts to upload Asset to Github Release. Creates Release if it doesn't exist """
  239. try:
  240. args = parse_args()
  241. if args.dry_run:
  242. if os.path.isdir(args.path):
  243. onlyfiles = [f for f in listdir(args.path) if isfile(join(args.path, f))]
  244. for filename in onlyfiles:
  245. binary_path = os.path.join(args.path, filename)
  246. logging.info("binary: " + binary_path)
  247. assert_asset_version(binary_path, args.release_version)
  248. elif os.path.isfile(args.path):
  249. logging.info("binary: " + binary_path)
  250. else:
  251. logging.error("dryrun failed")
  252. return
  253. else:
  254. client = Github(args.api_key)
  255. repo = client.get_repo(CLOUDFLARED_REPO)
  256. if os.path.isdir(args.path):
  257. onlyfiles = [f for f in listdir(args.path) if isfile(join(args.path, f))]
  258. for filename in onlyfiles:
  259. binary_path = os.path.join(args.path, filename)
  260. assert_asset_version(binary_path, args.release_version)
  261. release = get_or_create_release(repo, args.release_version, args.dry_run, args.draft)
  262. for filename in onlyfiles:
  263. binary_path = os.path.join(args.path, filename)
  264. upload_asset(release, binary_path, filename, args.release_version, args.kv_account_id, args.namespace_id,
  265. args.kv_api_token)
  266. move_asset(binary_path, filename)
  267. else:
  268. raise Exception("the argument path must be a directory")
  269. except Exception as e:
  270. logging.exception(e)
  271. exit(1)
  272. main()