You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

321 lines
12 KiB

  1. #!/usr/bin/env python3
  2. import datetime
  3. import json
  4. import jwt
  5. import os
  6. import re
  7. import requests
  8. import shutil
  9. import subprocess
  10. import sys
  11. import tempfile
  12. import time
  13. import zipfile
  14. from distutils.version import LooseVersion
  15. from string import Template
  16. # - Download target (raw) uMatrix.firefox.xpi from GitHub
  17. # - This is referred to as "raw" package
  18. # - This will fail if not a dev build
  19. # - Modify raw package to make it self-hosted
  20. # - This is referred to as "unsigned" package
  21. # - Ask AMO to sign uMatrix.firefox.xpi
  22. # - Generate JWT to be used for communication with server
  23. # - Upload unsigned package to AMO
  24. # - Wait for a valid download URL for signed package
  25. # - Download signed package as uMatrix.firefox.signed.xpi
  26. # - This is referred to as "signed" package
  27. # - Upload uMatrix.firefox.signed.xpi to GitHub
  28. # - Remove uMatrix.firefox.xpi from GitHub
  29. # - Modify updates.json to point to new version
  30. # - Commit changes to repo
  31. # Find path to project root
  32. projdir = os.path.split(os.path.abspath(__file__))[0]
  33. while not os.path.isdir(os.path.join(projdir, '.git')):
  34. projdir = os.path.normpath(os.path.join(projdir, '..'))
  35. # Check that found project root is valid
  36. version_filepath = os.path.join(projdir, 'dist', 'version')
  37. if not os.path.isfile(version_filepath):
  38. print('Version file not found.')
  39. exit(1)
  40. # We need a version string to work with
  41. if len(sys.argv) >= 2 and sys.argv[1]:
  42. tag_version = sys.argv[1]
  43. else:
  44. tag_version = input('Github release version: ')
  45. tag_version.strip()
  46. match = re.search('^(\d+\.\d+\.\d+)(?:(b|rc)(\d+))?$', tag_version)
  47. if not match:
  48. print('Error: Invalid version string.')
  49. exit(1)
  50. ext_version = match.group(1);
  51. if match.group(2):
  52. revision = int(match.group(3))
  53. if match.group(2) == 'rc':
  54. revision += 100;
  55. ext_version += '.' + str(revision)
  56. extension_id = 'uMatrix@raymondhill.net'
  57. tmpdir = tempfile.TemporaryDirectory()
  58. raw_xpi_filename = 'uMatrix_' + tag_version + '.firefox.xpi'
  59. raw_xpi_filepath = os.path.join(tmpdir.name, raw_xpi_filename)
  60. unsigned_xpi_filepath = os.path.join(tmpdir.name, 'uMatrix.firefox.unsigned.xpi')
  61. signed_xpi_filename = 'uMatrix_' + tag_version + '.firefox.signed.xpi'
  62. signed_xpi_filepath = os.path.join(tmpdir.name, signed_xpi_filename)
  63. github_owner = 'gorhill'
  64. github_repo = 'uMatrix'
  65. # Load/save auth secrets
  66. # The build directory is excluded from git
  67. ubo_secrets = dict()
  68. ubo_secrets_filename = os.path.join(projdir, 'dist', 'build', 'ubo_secrets')
  69. if os.path.isfile(ubo_secrets_filename):
  70. with open(ubo_secrets_filename) as f:
  71. ubo_secrets = json.load(f)
  72. def input_secret(prompt, token):
  73. if token in ubo_secrets:
  74. prompt += ''
  75. prompt += ': '
  76. value = input(prompt).strip()
  77. if len(value) == 0:
  78. if token not in ubo_secrets:
  79. print('Token error:', token)
  80. exit(1)
  81. value = ubo_secrets[token]
  82. elif token not in ubo_secrets or value != ubo_secrets[token]:
  83. ubo_secrets[token] = value
  84. exists = os.path.isfile(ubo_secrets_filename)
  85. with open(ubo_secrets_filename, 'w') as f:
  86. json.dump(ubo_secrets, f, indent=2)
  87. if not exists:
  88. os.chmod(ubo_secrets_filename, 0o600)
  89. return value
  90. # GitHub API token
  91. github_token = input_secret('Github token', 'github_token')
  92. github_auth = 'token ' + github_token
  93. #
  94. # Get metadata from GitHub about the release
  95. #
  96. # https://developer.github.com/v3/repos/releases/#get-a-single-release
  97. print('Downloading release info from GitHub...')
  98. release_info_url = 'https://api.github.com/repos/{0}/{1}/releases/tags/{2}'.format(github_owner, github_repo, tag_version)
  99. headers = { 'Authorization': github_auth, }
  100. response = requests.get(release_info_url, headers=headers)
  101. if response.status_code != 200:
  102. print('Error: Release not found: {0}'.format(response.status_code))
  103. exit(1)
  104. release_info = response.json()
  105. #
  106. # Extract URL to raw package from metadata
  107. #
  108. # Find url for uMatrix.firefox.xpi
  109. raw_xpi_url = ''
  110. for asset in release_info['assets']:
  111. if asset['name'] == signed_xpi_filename:
  112. print('Error: Found existing signed self-hosted package.')
  113. exit(1)
  114. if asset['name'] == raw_xpi_filename:
  115. raw_xpi_url = asset['url']
  116. if len(raw_xpi_url) == 0:
  117. print('Error: Release asset URL not found')
  118. exit(1)
  119. #
  120. # Download raw package from GitHub
  121. #
  122. # https://developer.github.com/v3/repos/releases/#get-a-single-release-asset
  123. print('Downloading raw xpi package from GitHub...')
  124. headers = {
  125. 'Authorization': github_auth,
  126. 'Accept': 'application/octet-stream',
  127. }
  128. response = requests.get(raw_xpi_url, headers=headers)
  129. # Redirections are transparently handled:
  130. # http://docs.python-requests.org/en/master/user/quickstart/#redirection-and-history
  131. if response.status_code != 200:
  132. print('Error: Downloading raw package failed -- server error {0}'.format(response.status_code))
  133. exit(1)
  134. with open(raw_xpi_filepath, 'wb') as f:
  135. f.write(response.content)
  136. print('Downloaded raw package saved as {0}'.format(raw_xpi_filepath))
  137. #
  138. # Convert the package to a self-hosted one: add `update_url` to the manifest
  139. #
  140. print('Converting raw xpi package into self-hosted xpi package...')
  141. with zipfile.ZipFile(raw_xpi_filepath, 'r') as zipin:
  142. with zipfile.ZipFile(unsigned_xpi_filepath, 'w') as zipout:
  143. for item in zipin.infolist():
  144. data = zipin.read(item.filename)
  145. if item.filename == 'manifest.json':
  146. manifest = json.loads(bytes.decode(data))
  147. manifest['browser_specific_settings']['gecko']['update_url'] = 'https://raw.githubusercontent.com/{0}/{1}/master/dist/firefox/updates.json'.format(github_owner, github_repo)
  148. data = json.dumps(manifest, indent=2, separators=(',', ': '), sort_keys=True).encode()
  149. zipout.writestr(item, data)
  150. #
  151. # Ask AMO to sign the self-hosted package
  152. # - https://developer.mozilla.org/en-US/Add-ons/Distribution#Distributing_your_add-on
  153. # - https://pyjwt.readthedocs.io/en/latest/usage.html
  154. # - https://addons-server.readthedocs.io/en/latest/topics/api/auth.html
  155. # - https://addons-server.readthedocs.io/en/latest/topics/api/signing.html
  156. #
  157. amo_api_key = ''
  158. amo_secret = ''
  159. def get_jwt_auth():
  160. global amo_api_key
  161. if amo_api_key == '':
  162. amo_api_key = input_secret('AMO API key', 'amo_api_key')
  163. global amo_secret
  164. if amo_secret == '':
  165. amo_secret = input_secret('AMO API secret', 'amo_secret')
  166. amo_nonce = os.urandom(8).hex()
  167. jwt_payload = {
  168. 'iss': amo_api_key,
  169. 'jti': amo_nonce,
  170. 'iat': datetime.datetime.utcnow(),
  171. 'exp': datetime.datetime.utcnow() + datetime.timedelta(seconds=15),
  172. }
  173. return 'JWT ' + jwt.encode(jwt_payload, amo_secret).decode()
  174. print('Ask AMO to sign self-hosted xpi package...')
  175. with open(unsigned_xpi_filepath, 'rb') as f:
  176. # https://blog.mozilla.org/addons/2019/11/11/security-improvements-in-amo-upload-tools/
  177. # "We recommend allowing up to 15 minutes."
  178. interval = 60 # check every 60 seconds
  179. countdown = 15 * 60 / interval # for at most 15 minutes
  180. headers = { 'Authorization': get_jwt_auth(), }
  181. data = { 'channel': 'unlisted' }
  182. files = { 'upload': f, }
  183. signing_url = 'https://addons.mozilla.org/api/v3/addons/{0}/versions/{1}/'.format(extension_id, ext_version)
  184. print('Submitting package to be signed...')
  185. response = requests.put(signing_url, headers=headers, data=data, files=files)
  186. if response.status_code != 202:
  187. print('Error: Creating new version failed -- server error {0}'.format(response.status_code))
  188. print(response.text)
  189. exit(1)
  190. print('Request for signing self-hosted xpi package succeeded.')
  191. signing_request_response = response.json();
  192. f.close()
  193. print('Waiting for AMO to process the request to sign the self-hosted xpi package...')
  194. # Wait for signed package to be ready
  195. signing_check_url = signing_request_response['url']
  196. while True:
  197. sys.stdout.write('.')
  198. sys.stdout.flush()
  199. time.sleep(interval)
  200. countdown -= 1
  201. if countdown <= 0:
  202. print('Error: AMO signing timed out')
  203. exit(1)
  204. headers = { 'Authorization': get_jwt_auth(), }
  205. response = requests.get(signing_check_url, headers=headers)
  206. if response.status_code != 200:
  207. print('Error: AMO signing failed -- server error {0}'.format(response.status_code))
  208. print(response.text)
  209. exit(1)
  210. signing_check_response = response.json()
  211. if not signing_check_response['processed']:
  212. continue
  213. if not signing_check_response['valid']:
  214. print('Error: AMO validation failed')
  215. print(response.text)
  216. exit(1)
  217. if not signing_check_response['files'] or len(signing_check_response['files']) == 0:
  218. continue
  219. if not signing_check_response['files'][0]['signed']:
  220. continue
  221. if not signing_check_response['files'][0]['download_url']:
  222. print('Error: AMO signing failed')
  223. print(response.text)
  224. exit(1)
  225. print('\r')
  226. print('Self-hosted xpi package successfully signed.')
  227. download_url = signing_check_response['files'][0]['download_url']
  228. print('Downloading signed self-hosted xpi package from {0}...'.format(download_url))
  229. response = requests.get(download_url, headers=headers)
  230. if response.status_code != 200:
  231. print('Error: Download signed package failed -- server error {0}'.format(response.status_code))
  232. print(response.text)
  233. exit(1)
  234. with open(signed_xpi_filepath, 'wb') as f:
  235. f.write(response.content)
  236. f.close()
  237. print('Signed self-hosted xpi package downloaded.')
  238. break
  239. #
  240. # Upload signed package to GitHub
  241. #
  242. # https://developer.github.com/v3/repos/releases/#upload-a-release-asset
  243. print('Uploading signed self-hosted xpi package to GitHub...')
  244. with open(signed_xpi_filepath, 'rb') as f:
  245. url = release_info['upload_url'].replace('{?name,label}', '?name=' + signed_xpi_filename)
  246. headers = {
  247. 'Authorization': github_auth,
  248. 'Content-Type': 'application/zip',
  249. }
  250. response = requests.post(url, headers=headers, data=f.read())
  251. if response.status_code != 201:
  252. print('Error: Upload signed package failed -- server error: {0}'.format(response.status_code))
  253. exit(1)
  254. #
  255. # Remove raw package from GitHub
  256. #
  257. # https://developer.github.com/v3/repos/releases/#delete-a-release-asset
  258. print('Remove raw xpi package from GitHub...')
  259. headers = { 'Authorization': github_auth, }
  260. response = requests.delete(raw_xpi_url, headers=headers)
  261. if response.status_code != 204:
  262. print('Error: Deletion of raw package failed -- server error: {0}'.format(response.status_code))
  263. #
  264. # Update updates.json to point to new package -- but only if just-signed
  265. # package is higher version than current one.
  266. #
  267. print('Update GitHub to point to newly signed self-hosted xpi package...')
  268. updates_json_filepath = os.path.join(projdir, 'dist', 'firefox', 'updates.json')
  269. with open(updates_json_filepath) as f:
  270. updates_json = json.load(f)
  271. f.close()
  272. previous_version = updates_json['addons'][extension_id]['updates'][0]['version']
  273. if LooseVersion(ext_version) > LooseVersion(previous_version):
  274. with open(os.path.join(projdir, 'dist', 'firefox', 'updates.template.json')) as f:
  275. template_json = Template(f.read())
  276. f.close()
  277. updates_json = template_json.substitute(ext_version=ext_version, tag_version=tag_version)
  278. with open(updates_json_filepath, 'w') as f:
  279. f.write(updates_json)
  280. f.close()
  281. # Automatically git add/commit if needed.
  282. # - Stage the changed file
  283. r = subprocess.run(['git', 'status', '-s', updates_json_filepath], stdout=subprocess.PIPE)
  284. rout = bytes.decode(r.stdout).strip()
  285. if len(rout) >= 2 and rout[1] == 'M':
  286. subprocess.run(['git', 'add', updates_json_filepath])
  287. # - Commit the staged file
  288. r = subprocess.run(['git', 'status', '-s', updates_json_filepath], stdout=subprocess.PIPE)
  289. rout = bytes.decode(r.stdout).strip()
  290. if len(rout) >= 2 and rout[0] == 'M':
  291. subprocess.run(['git', 'commit', '-m', 'Make Firefox dev build auto-update', updates_json_filepath])
  292. subprocess.run(['git', 'push', 'origin', 'HEAD'])
  293. print('All done.')