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.

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