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.

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