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.

309 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.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.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. print('Ask AMO to sign self-hosted xpi package...')
  158. with open(unsigned_xpi_filepath, 'rb') as f:
  159. amo_api_key = input_secret('AMO API key', 'amo_api_key')
  160. amo_secret = input_secret('AMO API secret', 'amo_secret')
  161. amo_nonce = os.urandom(8).hex()
  162. jwt_payload = {
  163. 'iss': amo_api_key,
  164. 'jti': amo_nonce,
  165. 'iat': datetime.datetime.utcnow(),
  166. 'exp': datetime.datetime.utcnow() + datetime.timedelta(seconds=180),
  167. }
  168. jwt_auth = 'JWT ' + jwt.encode(jwt_payload, amo_secret).decode()
  169. headers = { 'Authorization': jwt_auth, }
  170. data = { 'channel': 'unlisted' }
  171. files = { 'upload': f, }
  172. signing_url = 'https://addons.mozilla.org/api/v3/addons/{0}/versions/{1}/'.format(extension_id, ext_version)
  173. print('Submitting package to be signed...')
  174. response = requests.put(signing_url, headers=headers, data=data, files=files)
  175. if response.status_code != 202:
  176. print('Error: Creating new version failed -- server error {0}'.format(response.status_code))
  177. print(response.text)
  178. exit(1)
  179. print('Request for signing self-hosted xpi package succeeded.')
  180. signing_request_response = response.json();
  181. f.close()
  182. print('Waiting for AMO to process the request to sign the self-hosted xpi package...')
  183. # Wait for signed package to be ready
  184. signing_check_url = signing_request_response['url']
  185. # TODO: use real time instead
  186. # https://blog.mozilla.org/addons/2019/11/11/security-improvements-in-amo-upload-tools/
  187. # "We recommend allowing up to 15 minutes."
  188. interval = 30 # check every 30 seconds
  189. countdown = 15 * 60 / interval # for at most 15 minutes
  190. while True:
  191. sys.stdout.write('.')
  192. sys.stdout.flush()
  193. time.sleep(interval)
  194. countdown -= 1
  195. if countdown <= 0:
  196. print('Error: AMO signing timed out')
  197. exit(1)
  198. response = requests.get(signing_check_url, headers=headers)
  199. if response.status_code != 200:
  200. print('Error: AMO signing failed -- server error {0}'.format(response.status_code))
  201. exit(1)
  202. signing_check_response = response.json()
  203. if not signing_check_response['processed']:
  204. continue
  205. if not signing_check_response['valid']:
  206. print('Error: AMO validation failed')
  207. exit(1)
  208. if not signing_check_response['files'] or len(signing_check_response['files']) == 0:
  209. continue
  210. if not signing_check_response['files'][0]['signed']:
  211. continue
  212. if not signing_check_response['files'][0]['download_url']:
  213. print('Error: AMO signing failed')
  214. print(response.text)
  215. exit(1)
  216. print('\r')
  217. print('Self-hosted xpi package successfully signed.')
  218. download_url = signing_check_response['files'][0]['download_url']
  219. print('Downloading signed self-hosted xpi package from {0}...'.format(download_url))
  220. response = requests.get(download_url, headers=headers)
  221. if response.status_code != 200:
  222. print('Error: Download signed package failed -- server error {0}'.format(response.status_code))
  223. exit(1)
  224. with open(signed_xpi_filepath, 'wb') as f:
  225. f.write(response.content)
  226. f.close()
  227. print('Signed self-hosted xpi package downloaded.')
  228. break
  229. #
  230. # Upload signed package to GitHub
  231. #
  232. # https://developer.github.com/v3/repos/releases/#upload-a-release-asset
  233. print('Uploading signed self-hosted xpi package to GitHub...')
  234. with open(signed_xpi_filepath, 'rb') as f:
  235. url = release_info['upload_url'].replace('{?name,label}', '?name=' + signed_xpi_filename)
  236. headers = {
  237. 'Authorization': github_auth,
  238. 'Content-Type': 'application/zip',
  239. }
  240. response = requests.post(url, headers=headers, data=f.read())
  241. if response.status_code != 201:
  242. print('Error: Upload signed package failed -- server error: {0}'.format(response.status_code))
  243. exit(1)
  244. #
  245. # Remove raw package from GitHub
  246. #
  247. # https://developer.github.com/v3/repos/releases/#delete-a-release-asset
  248. print('Remove raw xpi package from GitHub...')
  249. headers = { 'Authorization': github_auth, }
  250. response = requests.delete(raw_xpi_url, headers=headers)
  251. if response.status_code != 204:
  252. print('Error: Deletion of raw package failed -- server error: {0}'.format(response.status_code))
  253. #
  254. # Update updates.json to point to new package -- but only if just-signed
  255. # package is higher version than current one.
  256. #
  257. print('Update GitHub to point to newly signed self-hosted xpi package...')
  258. updates_json_filepath = os.path.join(projdir, 'dist', 'firefox', 'updates.json')
  259. with open(updates_json_filepath) as f:
  260. updates_json = json.load(f)
  261. f.close()
  262. previous_version = updates_json['addons'][extension_id]['updates'][0]['version']
  263. if LooseVersion(ext_version) > LooseVersion(previous_version):
  264. with open(os.path.join(projdir, 'dist', 'firefox', 'updates.template.json')) as f:
  265. template_json = Template(f.read())
  266. f.close()
  267. updates_json = template_json.substitute(ext_version=ext_version, tag_version=tag_version)
  268. with open(updates_json_filepath, 'w') as f:
  269. f.write(updates_json)
  270. f.close()
  271. # Automatically git add/commit if needed.
  272. # - Stage the changed file
  273. r = subprocess.run(['git', 'status', '-s', updates_json_filepath], stdout=subprocess.PIPE)
  274. rout = bytes.decode(r.stdout).strip()
  275. if len(rout) >= 2 and rout[1] == 'M':
  276. subprocess.run(['git', 'add', updates_json_filepath])
  277. # - Commit the staged file
  278. r = subprocess.run(['git', 'status', '-s', updates_json_filepath], stdout=subprocess.PIPE)
  279. rout = bytes.decode(r.stdout).strip()
  280. if len(rout) >= 2 and rout[0] == 'M':
  281. subprocess.run(['git', 'commit', '-m', 'Make Firefox dev build auto-update', updates_json_filepath])
  282. subprocess.run(['git', 'push', 'origin', 'HEAD'])
  283. print('All done.')