3 # nightly.py - part of the FDroid server tools
4 # Copyright (C) 2017 Hans-Christoph Steiner <hans@eds.org>
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
31 from urllib.parse import urlparse
32 from argparse import ArgumentParser
38 # hard coded defaults for Android ~/.android/debug.keystore files
39 # https://developers.google.com/android/guides/client-auth
40 KEYSTORE_FILE = os.path.join(os.getenv('HOME'), '.android', 'debug.keystore')
42 KEY_ALIAS = 'androiddebugkey'
43 DISTINGUISHED_NAME = 'CN=Android Debug,O=Android,C=US'
45 # standard suffix for naming fdroid git repos
49 def _ssh_key_from_debug_keystore():
50 tmp_dir = tempfile.mkdtemp(prefix='.')
51 privkey = os.path.join(tmp_dir, '.privkey')
52 key_pem = os.path.join(tmp_dir, '.key.pem')
53 p12 = os.path.join(tmp_dir, '.keystore.p12')
55 common.fill_config_defaults(_config)
56 subprocess.check_call([_config['keytool'], '-importkeystore',
57 '-srckeystore', KEYSTORE_FILE, '-srcalias', KEY_ALIAS,
58 '-srcstorepass', PASSWORD, '-srckeypass', PASSWORD,
59 '-destkeystore', p12, '-destalias', KEY_ALIAS,
60 '-deststorepass', PASSWORD, '-destkeypass', PASSWORD,
61 '-deststoretype', 'PKCS12'])
62 subprocess.check_call(['openssl', 'pkcs12', '-in', p12, '-out', key_pem,
63 '-passin', 'pass:' + PASSWORD, '-passout', 'pass:' + PASSWORD])
64 subprocess.check_call(['openssl', 'rsa', '-in', key_pem, '-out', privkey,
65 '-passin', 'pass:' + PASSWORD])
68 os.chmod(privkey, 0o600) # os.umask() should cover this, but just in case
70 rsakey = paramiko.RSAKey.from_private_key_file(privkey)
71 fingerprint = base64.b64encode(hashlib.sha256(rsakey.asbytes()).digest()).decode('ascii').rstrip('=')
72 ssh_private_key_file = os.path.join(tmp_dir, 'debug_keystore_' + fingerprint + '_id_rsa')
73 shutil.move(privkey, ssh_private_key_file)
75 pub = rsakey.get_name() + ' ' + rsakey.get_base64() + ' ' + ssh_private_key_file
76 with open(ssh_private_key_file + '.pub', 'w') as fp:
79 logging.info(_('\nSSH Public Key to be used as Deploy Key:') + '\n' + pub)
81 return ssh_private_key_file
86 parser = ArgumentParser(usage="%(prog)s")
87 common.setup_global_opts(parser)
88 parser.add_argument("--show-secret-var", action="store_true", default=False,
89 help=_("Print the secret variable to the terminal for easy copy/paste"))
90 parser.add_argument("--file", default='app/build/outputs/apk/*.apk',
91 help=_('The the file to be included in the repo (path or glob)'))
92 parser.add_argument("--no-checksum", action="store_true", default=False,
93 help=_("Don't use rsync checksums"))
94 # TODO add --with-btlog
95 options = parser.parse_args()
97 # force a tighter umask since this writes private key material
98 umask = os.umask(0o077)
100 if 'CI' in os.environ:
101 v = os.getenv('DEBUG_KEYSTORE')
102 debug_keystore = None
104 debug_keystore = base64.b64decode(v)
105 if not debug_keystore:
106 logging.error(_('DEBUG_KEYSTORE is not set or the value is incomplete'))
108 os.makedirs(os.path.dirname(KEYSTORE_FILE), exist_ok=True)
109 if os.path.exists(KEYSTORE_FILE):
110 logging.warning(_('overwriting existing {path}').format(path=KEYSTORE_FILE))
111 with open(KEYSTORE_FILE, 'wb') as fp:
112 fp.write(debug_keystore)
114 repo_basedir = os.path.join(os.getcwd(), 'fdroid')
115 repodir = os.path.join(repo_basedir, 'repo')
117 os.makedirs(repodir, exist_ok=True)
119 if 'CI_PROJECT_PATH' in os.environ and 'CI_PROJECT_URL' in os.environ:
120 # we are in GitLab CI
121 repo_git_base = os.getenv('CI_PROJECT_PATH') + NIGHTLY
122 clone_url = os.getenv('CI_PROJECT_URL') + NIGHTLY
123 repo_base = clone_url + '/raw/master/fdroid'
124 servergitmirror = 'git@' + urlparse(clone_url).netloc + ':' + repo_git_base
125 deploy_key_url = clone_url + '/settings/repository'
126 git_user_name = os.getenv('GITLAB_USER_NAME')
127 git_user_email = os.getenv('GITLAB_USER_EMAIL')
128 elif 'TRAVIS_REPO_SLUG' in os.environ:
129 # we are in Travis CI
130 repo_git_base = os.getenv('TRAVIS_REPO_SLUG') + NIGHTLY
131 clone_url = 'https://github.com/' + repo_git_base
132 _branch = os.getenv('TRAVIS_BRANCH')
133 repo_base = 'https://raw.githubusercontent.com/' + repo_git_base + '/' + _branch + '/fdroid'
134 servergitmirror = 'git@github.com:' + repo_git_base
135 deploy_key_url = ('https://github.com/' + repo_git_base + '/settings/keys'
136 + '\nhttps://developer.github.com/v3/guides/managing-deploy-keys/#deploy-keys')
137 git_user_name = repo_git_base
138 git_user_email = os.getenv('USER') + '@' + platform.node()
139 elif 'CIRCLE_REPOSITORY_URL' in os.environ \
140 and 'CIRCLE_PROJECT_USERNAME' in os.environ \
141 and 'CIRCLE_PROJECT_REPONAME' in os.environ:
142 # we are in Circle CI
143 repo_git_base = (os.getenv('CIRCLE_PROJECT_USERNAME')
144 + '/' + os.getenv('CIRCLE_PROJECT_REPONAME') + NIGHTLY)
145 clone_url = os.getenv('CIRCLE_REPOSITORY_URL') + NIGHTLY
146 repo_base = clone_url + '/raw/master/fdroid'
147 servergitmirror = 'git@' + urlparse(clone_url).netloc + ':' + repo_git_base
148 deploy_key_url = ('https://github.com/' + repo_git_base + '/settings/keys'
149 + '\nhttps://developer.github.com/v3/guides/managing-deploy-keys/#deploy-keys')
150 git_user_name = os.getenv('CIRCLE_USERNAME')
151 git_user_email = git_user_name + '@' + platform.node()
153 print(_('ERROR: unsupported CI type, patches welcome!'))
156 repo_url = repo_base + '/repo'
157 git_mirror_path = os.path.join(repo_basedir, 'git-mirror')
158 git_mirror_repodir = os.path.join(git_mirror_path, 'fdroid', 'repo')
159 git_mirror_metadatadir = os.path.join(git_mirror_path, 'fdroid', 'metadata')
160 if not os.path.isdir(git_mirror_repodir):
161 logging.debug(_('cloning {url}').format(url=clone_url))
163 git.Repo.clone_from(clone_url, git_mirror_path)
166 if not os.path.isdir(git_mirror_repodir):
167 os.makedirs(git_mirror_repodir, mode=0o755)
169 mirror_git_repo = git.Repo.init(git_mirror_path)
170 writer = mirror_git_repo.config_writer()
171 writer.set_value('user', 'name', git_user_name)
172 writer.set_value('user', 'email', git_user_email)
174 for remote in mirror_git_repo.remotes:
175 mirror_git_repo.delete_remote(remote)
177 readme_path = os.path.join(git_mirror_path, 'README.md')
181 [![{repo_url}](icon.png)]({repo_url})
183 Last updated: {date}'''.format(repo_git_base=repo_git_base,
185 date=datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC'))
186 with open(readme_path, 'w') as fp:
188 mirror_git_repo.git.add(all=True)
189 mirror_git_repo.index.commit("update README")
191 icon_path = os.path.join(git_mirror_path, 'icon.png')
194 img = qrcode.make(repo_url)
195 with open(icon_path, 'wb') as fp:
198 exampleicon = os.path.join(common.get_examples_dir(), 'fdroid-icon.png')
199 shutil.copy(exampleicon, icon_path)
200 mirror_git_repo.git.add(all=True)
201 mirror_git_repo.index.commit("update repo/website icon")
202 shutil.copy(icon_path, repo_basedir)
204 os.chdir(repo_basedir)
205 if os.path.isdir(git_mirror_repodir):
206 common.local_rsync(options, git_mirror_repodir + '/', 'repo/')
207 if os.path.isdir(git_mirror_metadatadir):
208 common.local_rsync(options, git_mirror_metadatadir + '/', 'metadata/')
210 ssh_private_key_file = _ssh_key_from_debug_keystore()
211 # this is needed for GitPython to find the SSH key
212 ssh_dir = os.path.join(os.getenv('HOME'), '.ssh')
213 os.makedirs(ssh_dir, exist_ok=True)
214 ssh_config = os.path.join(ssh_dir, 'config')
215 logging.debug(_('adding IdentityFile to {path}').format(path=ssh_config))
216 with open(ssh_config, 'a') as fp:
217 fp.write('\n\nHost *\n\tIdentityFile %s\n' % ssh_private_key_file)
220 config += "identity_file = '%s'\n" % ssh_private_key_file
221 config += "repo_name = '%s'\n" % repo_git_base
222 config += "repo_url = '%s'\n" % repo_url
223 config += "repo_icon = 'icon.png'\n"
224 config += "archive_name = '%s'\n" % (repo_git_base + ' archive')
225 config += "archive_url = '%s'\n" % (repo_base + '/archive')
226 config += "archive_icon = 'icon.png'\n"
227 config += "servergitmirrors = '%s'\n" % servergitmirror
228 config += "keystore = '%s'\n" % KEYSTORE_FILE
229 config += "repo_keyalias = '%s'\n" % KEY_ALIAS
230 config += "keystorepass = '%s'\n" % PASSWORD
231 config += "keypass = '%s'\n" % PASSWORD
232 config += "keydname = '%s'\n" % DISTINGUISHED_NAME
233 config += "make_current_version_link = False\n"
234 config += "accepted_formats = ('txt', 'yml')\n"
235 # TODO add update_stats = True
236 with open('config.py', 'w') as fp:
238 os.chmod('config.py', 0o600)
239 config = common.read_config(options)
240 common.assert_config_keystore(config)
242 for root, dirs, files in os.walk(cibase):
243 for d in ('fdroid', '.git', '.gradle'):
247 if f.endswith('-debug.apk'):
248 apkfilename = os.path.join(root, f)
249 logging.debug(_('Striping mystery signature from {apkfilename}')
250 .format(apkfilename=apkfilename))
251 destapk = os.path.join(repodir, os.path.basename(f))
252 os.chmod(apkfilename, 0o644)
253 logging.debug(_('Resigning {apkfilename} with provided debug.keystore')
254 .format(apkfilename=os.path.basename(apkfilename)))
255 common.apk_strip_signatures(apkfilename, strip_manifest=True)
256 common.sign_apk(apkfilename, destapk, KEY_ALIAS)
259 logging.debug(_('attempting bare ssh connection to test deploy key:'))
261 subprocess.check_call(['ssh', '-Tvi', ssh_private_key_file,
262 '-oIdentitiesOnly=yes', '-oStrictHostKeyChecking=no',
263 servergitmirror.split(':')[0]])
264 except subprocess.CalledProcessError:
267 subprocess.check_call(['fdroid', 'update', '--rename-apks', '--create-metadata', '--verbose'],
269 common.local_rsync(options, repo_basedir + '/metadata/', git_mirror_metadatadir + '/')
270 mirror_git_repo.git.add(all=True)
271 mirror_git_repo.index.commit("update app metadata")
273 subprocess.check_call(['fdroid', 'server', 'update', '--verbose'], cwd=repo_basedir)
274 except subprocess.CalledProcessError:
275 logging.error(_('cannot publish update, did you set the deploy key?')
276 + '\n' + deploy_key_url)
278 if shutil.rmtree.avoids_symlink_attacks:
279 shutil.rmtree(os.path.dirname(ssh_private_key_file))
282 ssh_dir = os.path.join(os.getenv('HOME'), '.ssh')
283 os.makedirs(os.path.dirname(ssh_dir), exist_ok=True)
284 privkey = _ssh_key_from_debug_keystore()
285 ssh_private_key_file = os.path.join(ssh_dir, os.path.basename(privkey))
286 shutil.move(privkey, ssh_private_key_file)
287 shutil.move(privkey + '.pub', ssh_private_key_file + '.pub')
288 if shutil.rmtree.avoids_symlink_attacks:
289 shutil.rmtree(os.path.dirname(privkey))
291 if options.show_secret_var:
292 with open(KEYSTORE_FILE, 'rb') as fp:
293 debug_keystore = base64.standard_b64encode(fp.read()).decode('ascii')
294 print(_('\n{path} encoded for the DEBUG_KEYSTORE secret variable:')
295 .format(path=KEYSTORE_FILE))
296 print(debug_keystore)
301 if __name__ == "__main__":