chiark / gitweb /
build: include buildserverid in build log for wiki
[fdroidserver.git] / fdroidserver / build.py
1 #!/usr/bin/env python3
2 #
3 # build.py - part of the FDroid server tools
4 # Copyright (C) 2010-2014, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 import sys
21 import os
22 import shutil
23 import glob
24 import subprocess
25 import re
26 import tarfile
27 import traceback
28 import time
29 import json
30 from configparser import ConfigParser
31 from argparse import ArgumentParser
32 import logging
33
34 from . import common
35 from . import net
36 from . import metadata
37 from . import scanner
38 from .common import FDroidException, BuildException, VCSException, FDroidPopen, SdkToolsPopen
39
40 try:
41     import paramiko
42 except ImportError:
43     pass
44
45
46 def get_builder_vm_id():
47     vd = os.path.join('builder', '.vagrant')
48     if os.path.isdir(vd):
49         # Vagrant 1.2 (and maybe 1.1?) it's a directory tree...
50         with open(os.path.join(vd, 'machines', 'default',
51                                'virtualbox', 'id')) as vf:
52             id = vf.read()
53         return id
54     else:
55         # Vagrant 1.0 - it's a json file...
56         with open(os.path.join('builder', '.vagrant')) as vf:
57             v = json.load(vf)
58         return v['active']['default']
59
60
61 def got_valid_builder_vm():
62     """Returns True if we have a valid-looking builder vm
63     """
64     if not os.path.exists(os.path.join('builder', 'Vagrantfile')):
65         return False
66     vd = os.path.join('builder', '.vagrant')
67     if not os.path.exists(vd):
68         return False
69     if not os.path.isdir(vd):
70         # Vagrant 1.0 - if the directory is there, it's valid...
71         return True
72     # Vagrant 1.2 - the directory can exist, but the id can be missing...
73     if not os.path.exists(os.path.join(vd, 'machines', 'default',
74                                        'virtualbox', 'id')):
75         return False
76     return True
77
78
79 def vagrant(params, cwd=None, printout=False):
80     """Run a vagrant command.
81
82     :param: list of parameters to pass to vagrant
83     :cwd: directory to run in, or None for current directory
84     :returns: (ret, out) where ret is the return code, and out
85                is the stdout (and stderr) from vagrant
86     """
87     p = FDroidPopen(['vagrant'] + params, cwd=cwd)
88     return (p.returncode, p.output)
89
90
91 def get_vagrant_sshinfo():
92     """Get ssh connection info for a vagrant VM
93
94     :returns: A dictionary containing 'hostname', 'port', 'user'
95         and 'idfile'
96     """
97     if subprocess.call('vagrant ssh-config >sshconfig',
98                        cwd='builder', shell=True) != 0:
99         raise BuildException("Error getting ssh config")
100     vagranthost = 'default'  # Host in ssh config file
101     sshconfig = paramiko.SSHConfig()
102     sshf = open(os.path.join('builder', 'sshconfig'), 'r')
103     sshconfig.parse(sshf)
104     sshf.close()
105     sshconfig = sshconfig.lookup(vagranthost)
106     idfile = sshconfig['identityfile']
107     if isinstance(idfile, list):
108         idfile = idfile[0]
109     elif idfile.startswith('"') and idfile.endswith('"'):
110         idfile = idfile[1:-1]
111     return {'hostname': sshconfig['hostname'],
112             'port': int(sshconfig['port']),
113             'user': sshconfig['user'],
114             'idfile': idfile}
115
116
117 def get_clean_vm(reset=False):
118     """Get a clean VM ready to do a buildserver build.
119
120     This might involve creating and starting a new virtual machine from
121     scratch, or it might be as simple (unless overridden by the reset
122     parameter) as re-using a snapshot created previously.
123
124     A BuildException will be raised if anything goes wrong.
125
126     :reset: True to force creating from scratch.
127     :returns: A dictionary containing 'hostname', 'port', 'user'
128         and 'idfile'
129     """
130     # Reset existing builder machine to a clean state if possible.
131     vm_ok = False
132     if not reset:
133         logging.info("Checking for valid existing build server")
134
135         if got_valid_builder_vm():
136             logging.info("...VM is present")
137             p = FDroidPopen(['VBoxManage', 'snapshot',
138                              get_builder_vm_id(), 'list',
139                              '--details'], cwd='builder')
140             if 'fdroidclean' in p.output:
141                 logging.info("...snapshot exists - resetting build server to "
142                              "clean state")
143                 retcode, output = vagrant(['status'], cwd='builder')
144
145                 if 'running' in output:
146                     logging.info("...suspending")
147                     vagrant(['suspend'], cwd='builder')
148                     logging.info("...waiting a sec...")
149                     time.sleep(10)
150                 p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
151                                  'restore', 'fdroidclean'],
152                                 cwd='builder')
153
154                 if p.returncode == 0:
155                     logging.info("...reset to snapshot - server is valid")
156                     retcode, output = vagrant(['up'], cwd='builder')
157                     if retcode != 0:
158                         raise BuildException("Failed to start build server")
159                     logging.info("...waiting a sec...")
160                     time.sleep(10)
161                     sshinfo = get_vagrant_sshinfo()
162                     vm_ok = True
163                 else:
164                     logging.info("...failed to reset to snapshot")
165             else:
166                 logging.info("...snapshot doesn't exist - "
167                              "VBoxManage snapshot list:\n" + p.output)
168
169     # If we can't use the existing machine for any reason, make a
170     # new one from scratch.
171     if not vm_ok:
172         if os.path.exists('builder'):
173             logging.info("Removing broken/incomplete/unwanted build server")
174             vagrant(['destroy', '-f'], cwd='builder')
175             shutil.rmtree('builder')
176         os.mkdir('builder')
177
178         p = subprocess.Popen(['vagrant', '--version'],
179                              universal_newlines=True,
180                              stdout=subprocess.PIPE)
181         vver = p.communicate()[0].strip().split(' ')[1]
182         if vver.split('.')[0] != '1' or int(vver.split('.')[1]) < 4:
183             raise BuildException("Unsupported vagrant version {0}".format(vver))
184
185         with open(os.path.join('builder', 'Vagrantfile'), 'w') as vf:
186             vf.write('Vagrant.configure("2") do |config|\n')
187             vf.write('config.vm.box = "buildserver"\n')
188             vf.write('config.vm.synced_folder ".", "/vagrant", disabled: true\n')
189             vf.write('end\n')
190
191         logging.info("Starting new build server")
192         retcode, _ = vagrant(['up'], cwd='builder')
193         if retcode != 0:
194             raise BuildException("Failed to start build server")
195
196         # Open SSH connection to make sure it's working and ready...
197         logging.info("Connecting to virtual machine...")
198         sshinfo = get_vagrant_sshinfo()
199         sshs = paramiko.SSHClient()
200         sshs.set_missing_host_key_policy(paramiko.AutoAddPolicy())
201         sshs.connect(sshinfo['hostname'], username=sshinfo['user'],
202                      port=sshinfo['port'], timeout=300,
203                      look_for_keys=False,
204                      key_filename=sshinfo['idfile'])
205         sshs.close()
206
207         logging.info("Saving clean state of new build server")
208         retcode, _ = vagrant(['suspend'], cwd='builder')
209         if retcode != 0:
210             raise BuildException("Failed to suspend build server")
211         logging.info("...waiting a sec...")
212         time.sleep(10)
213         p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
214                          'take', 'fdroidclean'],
215                         cwd='builder')
216         if p.returncode != 0:
217             raise BuildException("Failed to take snapshot")
218         logging.info("...waiting a sec...")
219         time.sleep(10)
220         logging.info("Restarting new build server")
221         retcode, _ = vagrant(['up'], cwd='builder')
222         if retcode != 0:
223             raise BuildException("Failed to start build server")
224         logging.info("...waiting a sec...")
225         time.sleep(10)
226         # Make sure it worked...
227         p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
228                          'list', '--details'],
229                         cwd='builder')
230         if 'fdroidclean' not in p.output:
231             raise BuildException("Failed to take snapshot.")
232
233     return sshinfo
234
235
236 def release_vm():
237     """Release the VM previously started with get_clean_vm().
238
239     This should always be called.
240     """
241     logging.info("Suspending build server")
242     subprocess.call(['vagrant', 'suspend'], cwd='builder')
243
244
245 # Note that 'force' here also implies test mode.
246 def build_server(app, build, vcs, build_dir, output_dir, force):
247     """Do a build on the build server."""
248
249     global buildserverid
250
251     try:
252         paramiko
253     except NameError:
254         raise BuildException("Paramiko is required to use the buildserver")
255     if options.verbose:
256         logging.getLogger("paramiko").setLevel(logging.INFO)
257     else:
258         logging.getLogger("paramiko").setLevel(logging.WARN)
259
260     sshinfo = get_clean_vm()
261
262     try:
263         if not buildserverid:
264             buildserverid = subprocess.check_output(['vagrant', 'ssh', '-c',
265                                                      'cat /home/vagrant/buildserverid'],
266                                                     cwd='builder').rstrip()
267
268         # Open SSH connection...
269         logging.info("Connecting to virtual machine...")
270         sshs = paramiko.SSHClient()
271         sshs.set_missing_host_key_policy(paramiko.AutoAddPolicy())
272         sshs.connect(sshinfo['hostname'], username=sshinfo['user'],
273                      port=sshinfo['port'], timeout=300,
274                      look_for_keys=False, key_filename=sshinfo['idfile'])
275
276         homedir = '/home/' + sshinfo['user']
277
278         # Get an SFTP connection...
279         ftp = sshs.open_sftp()
280         ftp.get_channel().settimeout(60)
281
282         # Put all the necessary files in place...
283         ftp.chdir(homedir)
284
285         # Helper to copy the contents of a directory to the server...
286         def send_dir(path):
287             root = os.path.dirname(path)
288             main = os.path.basename(path)
289             ftp.mkdir(main)
290             for r, d, f in os.walk(path):
291                 rr = os.path.relpath(r, root)
292                 ftp.chdir(rr)
293                 for dd in d:
294                     ftp.mkdir(dd)
295                 for ff in f:
296                     lfile = os.path.join(root, rr, ff)
297                     if not os.path.islink(lfile):
298                         ftp.put(lfile, ff)
299                         ftp.chmod(ff, os.stat(lfile).st_mode)
300                 for i in range(len(rr.split('/'))):
301                     ftp.chdir('..')
302             ftp.chdir('..')
303
304         logging.info("Preparing server for build...")
305         serverpath = os.path.abspath(os.path.dirname(__file__))
306         ftp.mkdir('fdroidserver')
307         ftp.chdir('fdroidserver')
308         ftp.put(os.path.join(serverpath, '..', 'fdroid'), 'fdroid')
309         ftp.chmod('fdroid', 0o755)
310         send_dir(os.path.join(serverpath))
311         ftp.chdir(homedir)
312
313         ftp.put(os.path.join(serverpath, '..', 'buildserver',
314                              'config.buildserver.py'), 'config.py')
315         ftp.chmod('config.py', 0o600)
316
317         # Copy over the ID (head commit hash) of the fdroidserver in use...
318         subprocess.call('git rev-parse HEAD >' +
319                         os.path.join(os.getcwd(), 'tmp', 'fdroidserverid'),
320                         shell=True, cwd=serverpath)
321         ftp.put('tmp/fdroidserverid', 'fdroidserverid')
322
323         # Copy the metadata - just the file for this app...
324         ftp.mkdir('metadata')
325         ftp.mkdir('srclibs')
326         ftp.chdir('metadata')
327         ftp.put(os.path.join('metadata', app.id + '.txt'),
328                 app.id + '.txt')
329         # And patches if there are any...
330         if os.path.exists(os.path.join('metadata', app.id)):
331             send_dir(os.path.join('metadata', app.id))
332
333         ftp.chdir(homedir)
334         # Create the build directory...
335         ftp.mkdir('build')
336         ftp.chdir('build')
337         ftp.mkdir('extlib')
338         ftp.mkdir('srclib')
339         # Copy any extlibs that are required...
340         if build.extlibs:
341             ftp.chdir(homedir + '/build/extlib')
342             for lib in build.extlibs:
343                 lib = lib.strip()
344                 libsrc = os.path.join('build/extlib', lib)
345                 if not os.path.exists(libsrc):
346                     raise BuildException("Missing extlib {0}".format(libsrc))
347                 lp = lib.split('/')
348                 for d in lp[:-1]:
349                     if d not in ftp.listdir():
350                         ftp.mkdir(d)
351                     ftp.chdir(d)
352                 ftp.put(libsrc, lp[-1])
353                 for _ in lp[:-1]:
354                     ftp.chdir('..')
355         # Copy any srclibs that are required...
356         srclibpaths = []
357         if build.srclibs:
358             for lib in build.srclibs:
359                 srclibpaths.append(
360                     common.getsrclib(lib, 'build/srclib', basepath=True, prepare=False))
361
362         # If one was used for the main source, add that too.
363         basesrclib = vcs.getsrclib()
364         if basesrclib:
365             srclibpaths.append(basesrclib)
366         for name, number, lib in srclibpaths:
367             logging.info("Sending srclib '%s'" % lib)
368             ftp.chdir(homedir + '/build/srclib')
369             if not os.path.exists(lib):
370                 raise BuildException("Missing srclib directory '" + lib + "'")
371             fv = '.fdroidvcs-' + name
372             ftp.put(os.path.join('build/srclib', fv), fv)
373             send_dir(lib)
374             # Copy the metadata file too...
375             ftp.chdir(homedir + '/srclibs')
376             ftp.put(os.path.join('srclibs', name + '.txt'),
377                     name + '.txt')
378         # Copy the main app source code
379         # (no need if it's a srclib)
380         if (not basesrclib) and os.path.exists(build_dir):
381             ftp.chdir(homedir + '/build')
382             fv = '.fdroidvcs-' + app.id
383             ftp.put(os.path.join('build', fv), fv)
384             send_dir(build_dir)
385
386         # Execute the build script...
387         logging.info("Starting build...")
388         chan = sshs.get_transport().open_session()
389         chan.get_pty()
390         cmdline = os.path.join(homedir, 'fdroidserver', 'fdroid')
391         cmdline += ' build --on-server'
392         if force:
393             cmdline += ' --force --test'
394         if options.verbose:
395             cmdline += ' --verbose'
396         cmdline += " %s:%s" % (app.id, build.vercode)
397         chan.exec_command('bash --login -c "' + cmdline + '"')
398
399         output = bytes()
400         output += b'== Installed Android Tools ==\n\n'
401         output += get_android_tools_version_log(build.ndk_path()).encode()
402         while not chan.exit_status_ready():
403             while chan.recv_ready():
404                 output += chan.recv(1024)
405             time.sleep(0.1)
406         logging.info("...getting exit status")
407         returncode = chan.recv_exit_status()
408         while True:
409             get = chan.recv(1024)
410             if len(get) == 0:
411                 break
412             output += get
413         if returncode != 0:
414             raise BuildException(
415                 "Build.py failed on server for {0}:{1}".format(
416                     app.id, build.version), str(output, 'utf-8'))
417
418         # Retrieve the built files...
419         logging.info("Retrieving build output...")
420         if force:
421             ftp.chdir(homedir + '/tmp')
422         else:
423             ftp.chdir(homedir + '/unsigned')
424         apkfile = common.get_release_filename(app, build)
425         tarball = common.getsrcname(app, build)
426         try:
427             ftp.get(apkfile, os.path.join(output_dir, apkfile))
428             if not options.notarball:
429                 ftp.get(tarball, os.path.join(output_dir, tarball))
430         except:
431             raise BuildException(
432                 "Build failed for %s:%s - missing output files".format(
433                     app.id, build.version), output)
434         ftp.close()
435
436     finally:
437
438         # Suspend the build server.
439         release_vm()
440
441
442 def force_gradle_build_tools(build_dir, build_tools):
443     for root, dirs, files in os.walk(build_dir):
444         for filename in files:
445             if not filename.endswith('.gradle'):
446                 continue
447             path = os.path.join(root, filename)
448             if not os.path.isfile(path):
449                 continue
450             logging.debug("Forcing build-tools %s in %s" % (build_tools, path))
451             common.regsub_file(r"""(\s*)buildToolsVersion([\s=]+).*""",
452                                r"""\1buildToolsVersion\2'%s'""" % build_tools,
453                                path)
454
455
456 def capitalize_intact(string):
457     """Like str.capitalize(), but leave the rest of the string intact without
458     switching it to lowercase."""
459     if len(string) == 0:
460         return string
461     if len(string) == 1:
462         return string.upper()
463     return string[0].upper() + string[1:]
464
465
466 def get_metadata_from_apk(app, build, apkfile):
467     """get the required metadata from the built APK"""
468
469     p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
470
471     vercode = None
472     version = None
473     foundid = None
474     nativecode = None
475     for line in p.output.splitlines():
476         if line.startswith("package:"):
477             pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
478             m = pat.match(line)
479             if m:
480                 foundid = m.group(1)
481             pat = re.compile(".*versionCode='([0-9]*)'.*")
482             m = pat.match(line)
483             if m:
484                 vercode = m.group(1)
485             pat = re.compile(".*versionName='([^']*)'.*")
486             m = pat.match(line)
487             if m:
488                 version = m.group(1)
489         elif line.startswith("native-code:"):
490             nativecode = line[12:]
491
492     # Ignore empty strings or any kind of space/newline chars that we don't
493     # care about
494     if nativecode is not None:
495         nativecode = nativecode.strip()
496         nativecode = None if not nativecode else nativecode
497
498     if build.buildjni and build.buildjni != ['no']:
499         if nativecode is None:
500             raise BuildException("Native code should have been built but none was packaged")
501     if build.novcheck:
502         vercode = build.vercode
503         version = build.version
504     if not version or not vercode:
505         raise BuildException("Could not find version information in build in output")
506     if not foundid:
507         raise BuildException("Could not find package ID in output")
508     if foundid != app.id:
509         raise BuildException("Wrong package ID - build " + foundid + " but expected " + app.id)
510
511     return vercode, version
512
513
514 def build_local(app, build, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh):
515     """Do a build locally."""
516
517     ndk_path = build.ndk_path()
518     if build.ndk or (build.buildjni and build.buildjni != ['no']):
519         if not ndk_path:
520             logging.critical("Android NDK version '%s' could not be found!" % build.ndk or 'r12b')
521             logging.critical("Configured versions:")
522             for k, v in config['ndk_paths'].items():
523                 if k.endswith("_orig"):
524                     continue
525                 logging.critical("  %s: %s" % (k, v))
526             sys.exit(3)
527         elif not os.path.isdir(ndk_path):
528             logging.critical("Android NDK '%s' is not a directory!" % ndk_path)
529             sys.exit(3)
530
531     common.set_FDroidPopen_env(build)
532
533     # Prepare the source code...
534     root_dir, srclibpaths = common.prepare_source(vcs, app, build,
535                                                   build_dir, srclib_dir,
536                                                   extlib_dir, onserver, refresh)
537
538     # We need to clean via the build tool in case the binary dirs are
539     # different from the default ones
540     p = None
541     gradletasks = []
542     bmethod = build.build_method()
543     if bmethod == 'maven':
544         logging.info("Cleaning Maven project...")
545         cmd = [config['mvn3'], 'clean', '-Dandroid.sdk.path=' + config['sdk_path']]
546
547         if '@' in build.maven:
548             maven_dir = os.path.join(root_dir, build.maven.split('@', 1)[1])
549             maven_dir = os.path.normpath(maven_dir)
550         else:
551             maven_dir = root_dir
552
553         p = FDroidPopen(cmd, cwd=maven_dir)
554
555     elif bmethod == 'gradle':
556
557         logging.info("Cleaning Gradle project...")
558
559         if build.preassemble:
560             gradletasks += build.preassemble
561
562         flavours = build.gradle
563         if flavours == ['yes']:
564             flavours = []
565
566         flavours_cmd = ''.join([capitalize_intact(f) for f in flavours])
567
568         gradletasks += ['assemble' + flavours_cmd + 'Release']
569
570         if config['force_build_tools']:
571             force_gradle_build_tools(build_dir, config['build_tools'])
572             for name, number, libpath in srclibpaths:
573                 force_gradle_build_tools(libpath, config['build_tools'])
574
575         cmd = [config['gradle']]
576         if build.gradleprops:
577             cmd += ['-P' + kv for kv in build.gradleprops]
578
579         cmd += ['clean']
580
581         p = FDroidPopen(cmd, cwd=root_dir)
582
583     elif bmethod == 'kivy':
584         pass
585
586     elif bmethod == 'ant':
587         logging.info("Cleaning Ant project...")
588         p = FDroidPopen(['ant', 'clean'], cwd=root_dir)
589
590     if p is not None and p.returncode != 0:
591         raise BuildException("Error cleaning %s:%s" %
592                              (app.id, build.version), p.output)
593
594     for root, dirs, files in os.walk(build_dir):
595
596         def del_dirs(dl):
597             for d in dl:
598                 if d in dirs:
599                     shutil.rmtree(os.path.join(root, d))
600
601         def del_files(fl):
602             for f in fl:
603                 if f in files:
604                     os.remove(os.path.join(root, f))
605
606         if 'build.gradle' in files:
607             # Even when running clean, gradle stores task/artifact caches in
608             # .gradle/ as binary files. To avoid overcomplicating the scanner,
609             # manually delete them, just like `gradle clean` should have removed
610             # the build/ dirs.
611             del_dirs(['build', '.gradle'])
612             del_files(['gradlew', 'gradlew.bat'])
613
614         if 'pom.xml' in files:
615             del_dirs(['target'])
616
617         if any(f in files for f in ['ant.properties', 'project.properties', 'build.xml']):
618             del_dirs(['bin', 'gen'])
619
620         if 'jni' in dirs:
621             del_dirs(['obj'])
622
623     if options.skipscan:
624         if build.scandelete:
625             raise BuildException("Refusing to skip source scan since scandelete is present")
626     else:
627         # Scan before building...
628         logging.info("Scanning source for common problems...")
629         count = scanner.scan_source(build_dir, root_dir, build)
630         if count > 0:
631             if force:
632                 logging.warn('Scanner found %d problems' % count)
633             else:
634                 raise BuildException("Can't build due to %d errors while scanning" % count)
635
636     if not options.notarball:
637         # Build the source tarball right before we build the release...
638         logging.info("Creating source tarball...")
639         tarname = common.getsrcname(app, build)
640         tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
641
642         def tarexc(f):
643             return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
644         tarball.add(build_dir, tarname, exclude=tarexc)
645         tarball.close()
646
647     # Run a build command if one is required...
648     if build.build:
649         logging.info("Running 'build' commands in %s" % root_dir)
650         cmd = common.replace_config_vars(build.build, build)
651
652         # Substitute source library paths into commands...
653         for name, number, libpath in srclibpaths:
654             libpath = os.path.relpath(libpath, root_dir)
655             cmd = cmd.replace('$$' + name + '$$', libpath)
656
657         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
658
659         if p.returncode != 0:
660             raise BuildException("Error running build command for %s:%s" %
661                                  (app.id, build.version), p.output)
662
663     # Build native stuff if required...
664     if build.buildjni and build.buildjni != ['no']:
665         logging.info("Building the native code")
666         jni_components = build.buildjni
667
668         if jni_components == ['yes']:
669             jni_components = ['']
670         cmd = [os.path.join(ndk_path, "ndk-build"), "-j1"]
671         for d in jni_components:
672             if d:
673                 logging.info("Building native code in '%s'" % d)
674             else:
675                 logging.info("Building native code in the main project")
676             manifest = os.path.join(root_dir, d, 'AndroidManifest.xml')
677             if os.path.exists(manifest):
678                 # Read and write the whole AM.xml to fix newlines and avoid
679                 # the ndk r8c or later 'wordlist' errors. The outcome of this
680                 # under gnu/linux is the same as when using tools like
681                 # dos2unix, but the native python way is faster and will
682                 # work in non-unix systems.
683                 manifest_text = open(manifest, 'U').read()
684                 open(manifest, 'w').write(manifest_text)
685                 # In case the AM.xml read was big, free the memory
686                 del manifest_text
687             p = FDroidPopen(cmd, cwd=os.path.join(root_dir, d))
688             if p.returncode != 0:
689                 raise BuildException("NDK build failed for %s:%s" % (app.id, build.version), p.output)
690
691     p = None
692     # Build the release...
693     if bmethod == 'maven':
694         logging.info("Building Maven project...")
695
696         if '@' in build.maven:
697             maven_dir = os.path.join(root_dir, build.maven.split('@', 1)[1])
698         else:
699             maven_dir = root_dir
700
701         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
702                   '-Dmaven.jar.sign.skip=true', '-Dmaven.test.skip=true',
703                   '-Dandroid.sign.debug=false', '-Dandroid.release=true',
704                   'package']
705         if build.target:
706             target = build.target.split('-')[1]
707             common.regsub_file(r'<platform>[0-9]*</platform>',
708                                r'<platform>%s</platform>' % target,
709                                os.path.join(root_dir, 'pom.xml'))
710             if '@' in build.maven:
711                 common.regsub_file(r'<platform>[0-9]*</platform>',
712                                    r'<platform>%s</platform>' % target,
713                                    os.path.join(maven_dir, 'pom.xml'))
714
715         p = FDroidPopen(mvncmd, cwd=maven_dir)
716
717         bindir = os.path.join(root_dir, 'target')
718
719     elif bmethod == 'kivy':
720         logging.info("Building Kivy project...")
721
722         spec = os.path.join(root_dir, 'buildozer.spec')
723         if not os.path.exists(spec):
724             raise BuildException("Expected to find buildozer-compatible spec at {0}"
725                                  .format(spec))
726
727         defaults = {'orientation': 'landscape', 'icon': '',
728                     'permissions': '', 'android.api': "18"}
729         bconfig = ConfigParser(defaults, allow_no_value=True)
730         bconfig.read(spec)
731
732         distdir = os.path.join('python-for-android', 'dist', 'fdroid')
733         if os.path.exists(distdir):
734             shutil.rmtree(distdir)
735
736         modules = bconfig.get('app', 'requirements').split(',')
737
738         cmd = 'ANDROIDSDK=' + config['sdk_path']
739         cmd += ' ANDROIDNDK=' + ndk_path
740         cmd += ' ANDROIDNDKVER=' + build.ndk
741         cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
742         cmd += ' VIRTUALENV=virtualenv'
743         cmd += ' ./distribute.sh'
744         cmd += ' -m ' + "'" + ' '.join(modules) + "'"
745         cmd += ' -d fdroid'
746         p = subprocess.Popen(cmd, cwd='python-for-android', shell=True)
747         if p.returncode != 0:
748             raise BuildException("Distribute build failed")
749
750         cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
751         if cid != app.id:
752             raise BuildException("Package ID mismatch between metadata and spec")
753
754         orientation = bconfig.get('app', 'orientation', 'landscape')
755         if orientation == 'all':
756             orientation = 'sensor'
757
758         cmd = ['./build.py'
759                '--dir', root_dir,
760                '--name', bconfig.get('app', 'title'),
761                '--package', app.id,
762                '--version', bconfig.get('app', 'version'),
763                '--orientation', orientation
764                ]
765
766         perms = bconfig.get('app', 'permissions')
767         for perm in perms.split(','):
768             cmd.extend(['--permission', perm])
769
770         if config.get('app', 'fullscreen') == 0:
771             cmd.append('--window')
772
773         icon = bconfig.get('app', 'icon.filename')
774         if icon:
775             cmd.extend(['--icon', os.path.join(root_dir, icon)])
776
777         cmd.append('release')
778         p = FDroidPopen(cmd, cwd=distdir)
779
780     elif bmethod == 'gradle':
781         logging.info("Building Gradle project...")
782
783         cmd = [config['gradle']]
784         if build.gradleprops:
785             cmd += ['-P' + kv for kv in build.gradleprops]
786
787         cmd += gradletasks
788
789         p = FDroidPopen(cmd, cwd=root_dir)
790
791     elif bmethod == 'ant':
792         logging.info("Building Ant project...")
793         cmd = ['ant']
794         if build.antcommands:
795             cmd += build.antcommands
796         else:
797             cmd += ['release']
798         p = FDroidPopen(cmd, cwd=root_dir)
799
800         bindir = os.path.join(root_dir, 'bin')
801
802     if p is not None and p.returncode != 0:
803         raise BuildException("Build failed for %s:%s" % (app.id, build.version), p.output)
804     logging.info("Successfully built version " + build.version + ' of ' + app.id)
805
806     omethod = build.output_method()
807     if omethod == 'maven':
808         stdout_apk = '\n'.join([
809             line for line in p.output.splitlines() if any(
810                 a in line for a in ('.apk', '.ap_', '.jar'))])
811         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
812                      stdout_apk, re.S | re.M)
813         if not m:
814             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
815                          stdout_apk, re.S | re.M)
816         if not m:
817             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
818                          stdout_apk, re.S | re.M)
819
820         if not m:
821             m = re.match(r".*^\[INFO\] Building jar: .*/" + bindir + r"/(.+)\.jar",
822                          stdout_apk, re.S | re.M)
823         if not m:
824             raise BuildException('Failed to find output')
825         src = m.group(1)
826         src = os.path.join(bindir, src) + '.apk'
827     elif omethod == 'kivy':
828         src = os.path.join('python-for-android', 'dist', 'default', 'bin',
829                            '{0}-{1}-release.apk'.format(
830                                bconfig.get('app', 'title'),
831                                bconfig.get('app', 'version')))
832     elif omethod == 'gradle':
833         src = None
834         for apks_dir in [
835                 os.path.join(root_dir, 'build', 'outputs', 'apk'),
836                 os.path.join(root_dir, 'build', 'apk'),
837                 ]:
838             for apkglob in ['*-release-unsigned.apk', '*-unsigned.apk', '*.apk']:
839                 apks = glob.glob(os.path.join(apks_dir, apkglob))
840
841                 if len(apks) > 1:
842                     raise BuildException('More than one resulting apks found in %s' % apks_dir,
843                                          '\n'.join(apks))
844                 if len(apks) == 1:
845                     src = apks[0]
846                     break
847             if src is not None:
848                 break
849
850         if src is None:
851             raise BuildException('Failed to find any output apks')
852
853     elif omethod == 'ant':
854         stdout_apk = '\n'.join([
855             line for line in p.output.splitlines() if '.apk' in line])
856         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
857                        re.S | re.M).group(1)
858         src = os.path.join(bindir, src)
859     elif omethod == 'raw':
860         globpath = os.path.join(root_dir, build.output)
861         apks = glob.glob(globpath)
862         if len(apks) > 1:
863             raise BuildException('Multiple apks match %s' % globpath, '\n'.join(apks))
864         if len(apks) < 1:
865             raise BuildException('No apks match %s' % globpath)
866         src = os.path.normpath(apks[0])
867
868     # Make sure it's not debuggable...
869     if common.isApkAndDebuggable(src, config):
870         raise BuildException("APK is debuggable")
871
872     # By way of a sanity check, make sure the version and version
873     # code in our new apk match what we expect...
874     logging.debug("Checking " + src)
875     if not os.path.exists(src):
876         raise BuildException("Unsigned apk is not at expected location of " + src)
877
878     if common.get_file_extension(src) == 'apk':
879         vercode, version = get_metadata_from_apk(app, build, src)
880         if (version != build.version or vercode != build.vercode):
881             raise BuildException(("Unexpected version/version code in output;"
882                                   " APK: '%s' / '%s', "
883                                   " Expected: '%s' / '%s'")
884                                  % (version, str(vercode), build.version,
885                                     str(build.vercode)))
886     else:
887         vercode = build.vercode
888         version = build.version
889
890     # Add information for 'fdroid verify' to be able to reproduce the build
891     # environment.
892     if onserver:
893         metadir = os.path.join(tmp_dir, 'META-INF')
894         if not os.path.exists(metadir):
895             os.mkdir(metadir)
896         homedir = os.path.expanduser('~')
897         for fn in ['buildserverid', 'fdroidserverid']:
898             shutil.copyfile(os.path.join(homedir, fn),
899                             os.path.join(metadir, fn))
900             subprocess.call(['jar', 'uf', os.path.abspath(src),
901                              'META-INF/' + fn], cwd=tmp_dir)
902
903     # Copy the unsigned apk to our destination directory for further
904     # processing (by publish.py)...
905     dest = os.path.join(output_dir, common.get_release_filename(app, build))
906     shutil.copyfile(src, dest)
907
908     # Move the source tarball into the output directory...
909     if output_dir != tmp_dir and not options.notarball:
910         shutil.move(os.path.join(tmp_dir, tarname),
911                     os.path.join(output_dir, tarname))
912
913
914 def trybuild(app, build, build_dir, output_dir, also_check_dir, srclib_dir, extlib_dir,
915              tmp_dir, repo_dir, vcs, test, server, force, onserver, refresh):
916     """
917     Build a particular version of an application, if it needs building.
918
919     :param output_dir: The directory where the build output will go. Usually
920        this is the 'unsigned' directory.
921     :param repo_dir: The repo directory - used for checking if the build is
922        necessary.
923     :paaram also_check_dir: An additional location for checking if the build
924        is necessary (usually the archive repo)
925     :param test: True if building in test mode, in which case the build will
926        always happen, even if the output already exists. In test mode, the
927        output directory should be a temporary location, not any of the real
928        ones.
929
930     :returns: True if the build was done, False if it wasn't necessary.
931     """
932
933     dest_file = common.get_release_filename(app, build)
934
935     dest = os.path.join(output_dir, dest_file)
936     dest_repo = os.path.join(repo_dir, dest_file)
937
938     if not test:
939         if os.path.exists(dest) or os.path.exists(dest_repo):
940             return False
941
942         if also_check_dir:
943             dest_also = os.path.join(also_check_dir, dest_file)
944             if os.path.exists(dest_also):
945                 return False
946
947     if build.disable and not options.force:
948         return False
949
950     logging.info("Building version %s (%s) of %s" % (
951         build.version, build.vercode, app.id))
952
953     if server:
954         # When using server mode, still keep a local cache of the repo, by
955         # grabbing the source now.
956         vcs.gotorevision(build.commit)
957
958         build_server(app, build, vcs, build_dir, output_dir, force)
959     else:
960         build_local(app, build, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh)
961     return True
962
963
964 def get_android_tools_versions(ndk_path=None):
965     '''get a list of the versions of all installed Android SDK/NDK components'''
966
967     global config
968     sdk_path = config['sdk_path']
969     if sdk_path[-1] != '/':
970         sdk_path += '/'
971     components = []
972     if ndk_path:
973         ndk_release_txt = os.path.join(ndk_path, 'RELEASE.TXT')
974         if os.path.isfile(ndk_release_txt):
975             with open(ndk_release_txt, 'r') as fp:
976                 components.append((os.path.basename(ndk_path), fp.read()[:-1]))
977
978     pattern = re.compile('^Pkg.Revision=(.+)', re.MULTILINE)
979     for root, dirs, files in os.walk(sdk_path):
980         if 'source.properties' in files:
981             source_properties = os.path.join(root, 'source.properties')
982             with open(source_properties, 'r') as fp:
983                 m = pattern.search(fp.read())
984                 if m:
985                     components.append((root[len(sdk_path):], m.group(1)))
986
987     return components
988
989
990 def get_android_tools_version_log(ndk_path):
991     '''get a list of the versions of all installed Android SDK/NDK components'''
992     log = ''
993     components = get_android_tools_versions(ndk_path)
994     for name, version in sorted(components):
995         log += '* ' + name + ' (' + version + ')\n'
996
997     return log
998
999
1000 def parse_commandline():
1001     """Parse the command line. Returns options, parser."""
1002
1003     parser = ArgumentParser(usage="%(prog)s [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
1004     common.setup_global_opts(parser)
1005     parser.add_argument("appid", nargs='*', help="app-id with optional versioncode in the form APPID[:VERCODE]")
1006     parser.add_argument("-l", "--latest", action="store_true", default=False,
1007                         help="Build only the latest version of each package")
1008     parser.add_argument("-s", "--stop", action="store_true", default=False,
1009                         help="Make the build stop on exceptions")
1010     parser.add_argument("-t", "--test", action="store_true", default=False,
1011                         help="Test mode - put output in the tmp directory only, and always build, even if the output already exists.")
1012     parser.add_argument("--server", action="store_true", default=False,
1013                         help="Use build server")
1014     parser.add_argument("--resetserver", action="store_true", default=False,
1015                         help="Reset and create a brand new build server, even if the existing one appears to be ok.")
1016     parser.add_argument("--on-server", dest="onserver", action="store_true", default=False,
1017                         help="Specify that we're running on the build server")
1018     parser.add_argument("--skip-scan", dest="skipscan", action="store_true", default=False,
1019                         help="Skip scanning the source code for binaries and other problems")
1020     parser.add_argument("--dscanner", action="store_true", default=False,
1021                         help="Setup an emulator, install the apk on it and perform a drozer scan")
1022     parser.add_argument("--no-tarball", dest="notarball", action="store_true", default=False,
1023                         help="Don't create a source tarball, useful when testing a build")
1024     parser.add_argument("--no-refresh", dest="refresh", action="store_false", default=True,
1025                         help="Don't refresh the repository, useful when testing a build with no internet connection")
1026     parser.add_argument("-f", "--force", action="store_true", default=False,
1027                         help="Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode.")
1028     parser.add_argument("-a", "--all", action="store_true", default=False,
1029                         help="Build all applications available")
1030     parser.add_argument("-w", "--wiki", default=False, action="store_true",
1031                         help="Update the wiki")
1032     metadata.add_metadata_arguments(parser)
1033     options = parser.parse_args()
1034     metadata.warnings_action = options.W
1035
1036     # Force --stop with --on-server to get correct exit code
1037     if options.onserver:
1038         options.stop = True
1039
1040     if options.force and not options.test:
1041         parser.error("option %s: Force is only allowed in test mode" % "force")
1042
1043     return options, parser
1044
1045
1046 options = None
1047 config = None
1048 buildserverid = None
1049
1050
1051 def main():
1052
1053     global options, config, buildserverid
1054
1055     options, parser = parse_commandline()
1056
1057     # The defaults for .fdroid.* metadata that is included in a git repo are
1058     # different than for the standard metadata/ layout because expectations
1059     # are different.  In this case, the most common user will be the app
1060     # developer working on the latest update of the app on their own machine.
1061     local_metadata_files = common.get_local_metadata_files()
1062     if len(local_metadata_files) == 1:  # there is local metadata in an app's source
1063         config = dict(common.default_config)
1064         # `fdroid build` should build only the latest version by default since
1065         # most of the time the user will be building the most recent update
1066         if not options.all:
1067             options.latest = True
1068     elif len(local_metadata_files) > 1:
1069         raise FDroidException("Only one local metadata file allowed! Found: "
1070                               + " ".join(local_metadata_files))
1071     else:
1072         if not os.path.isdir('metadata') and len(local_metadata_files) == 0:
1073             raise FDroidException("No app metadata found, nothing to process!")
1074         if not options.appid and not options.all:
1075             parser.error("option %s: If you really want to build all the apps, use --all" % "all")
1076
1077     config = common.read_config(options)
1078
1079     if config['build_server_always']:
1080         options.server = True
1081     if options.resetserver and not options.server:
1082         parser.error("option %s: Using --resetserver without --server makes no sense" % "resetserver")
1083
1084     log_dir = 'logs'
1085     if not os.path.isdir(log_dir):
1086         logging.info("Creating log directory")
1087         os.makedirs(log_dir)
1088
1089     tmp_dir = 'tmp'
1090     if not os.path.isdir(tmp_dir):
1091         logging.info("Creating temporary directory")
1092         os.makedirs(tmp_dir)
1093
1094     if options.test:
1095         output_dir = tmp_dir
1096     else:
1097         output_dir = 'unsigned'
1098         if not os.path.isdir(output_dir):
1099             logging.info("Creating output directory")
1100             os.makedirs(output_dir)
1101
1102     if config['archive_older'] != 0:
1103         also_check_dir = 'archive'
1104     else:
1105         also_check_dir = None
1106
1107     repo_dir = 'repo'
1108
1109     build_dir = 'build'
1110     if not os.path.isdir(build_dir):
1111         logging.info("Creating build directory")
1112         os.makedirs(build_dir)
1113     srclib_dir = os.path.join(build_dir, 'srclib')
1114     extlib_dir = os.path.join(build_dir, 'extlib')
1115
1116     # Read all app and srclib metadata
1117     pkgs = common.read_pkg_args(options.appid, True)
1118     allapps = metadata.read_metadata(not options.onserver, pkgs)
1119     apps = common.read_app_args(options.appid, allapps, True)
1120
1121     for appid, app in list(apps.items()):
1122         if (app.Disabled and not options.force) or not app.RepoType or not app.builds:
1123             del apps[appid]
1124
1125     if not apps:
1126         raise FDroidException("No apps to process.")
1127
1128     if options.latest:
1129         for app in apps.values():
1130             for build in reversed(app.builds):
1131                 if build.disable and not options.force:
1132                     continue
1133                 app.builds = [build]
1134                 break
1135
1136     if options.wiki:
1137         import mwclient
1138         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
1139                              path=config['wiki_path'])
1140         site.login(config['wiki_user'], config['wiki_password'])
1141
1142     # Build applications...
1143     failed_apps = {}
1144     build_succeeded = []
1145     for appid, app in apps.items():
1146
1147         first = True
1148
1149         for build in app.builds:
1150             wikilog = None
1151             tools_version_log = '== Installed Android Tools ==\n\n'
1152             tools_version_log += get_android_tools_version_log(build.ndk_path())
1153             try:
1154
1155                 # For the first build of a particular app, we need to set up
1156                 # the source repo. We can reuse it on subsequent builds, if
1157                 # there are any.
1158                 if first:
1159                     vcs, build_dir = common.setup_vcs(app)
1160                     first = False
1161
1162                 logging.debug("Checking " + build.version)
1163                 if trybuild(app, build, build_dir, output_dir,
1164                             also_check_dir, srclib_dir, extlib_dir,
1165                             tmp_dir, repo_dir, vcs, options.test,
1166                             options.server, options.force,
1167                             options.onserver, options.refresh):
1168
1169                     if app.Binaries is not None:
1170                         # This is an app where we build from source, and
1171                         # verify the apk contents against a developer's
1172                         # binary. We get that binary now, and save it
1173                         # alongside our built one in the 'unsigend'
1174                         # directory.
1175                         url = app.Binaries
1176                         url = url.replace('%v', build.version)
1177                         url = url.replace('%c', str(build.vercode))
1178                         logging.info("...retrieving " + url)
1179                         of = "{0}_{1}.apk.binary".format(app.id, build.vercode)
1180                         of = os.path.join(output_dir, of)
1181                         net.download_file(url, local_filename=of)
1182
1183                     build_succeeded.append(app)
1184                     wikilog = "Build succeeded"
1185             except VCSException as vcse:
1186                 reason = str(vcse).split('\n', 1)[0] if options.verbose else str(vcse)
1187                 logging.error("VCS error while building app %s: %s" % (
1188                     appid, reason))
1189                 if options.stop:
1190                     sys.exit(1)
1191                 failed_apps[appid] = vcse
1192                 wikilog = str(vcse)
1193             except FDroidException as e:
1194                 with open(os.path.join(log_dir, appid + '.log'), 'a+') as f:
1195                     f.write('\n\n============================================================\n')
1196                     f.write('versionCode: %s\nversionName: %s\ncommit: %s\n' %
1197                             (build.vercode, build.version, build.commit))
1198                     f.write('Build completed at '
1199                             + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + '\n')
1200                     f.write('\n' + tools_version_log + '\n')
1201                     f.write(str(e))
1202                 logging.error("Could not build app %s: %s" % (appid, e))
1203                 if options.stop:
1204                     sys.exit(1)
1205                 failed_apps[appid] = e
1206                 wikilog = e.get_wikitext()
1207             except Exception as e:
1208                 logging.error("Could not build app %s due to unknown error: %s" % (
1209                     appid, traceback.format_exc()))
1210                 if options.stop:
1211                     sys.exit(1)
1212                 failed_apps[appid] = e
1213                 wikilog = str(e)
1214
1215             if options.wiki and wikilog:
1216                 try:
1217                     # Write a page with the last build log for this version code
1218                     lastbuildpage = appid + '/lastbuild_' + build.vercode
1219                     newpage = site.Pages[lastbuildpage]
1220                     with open(os.path.join('tmp', 'fdroidserverid')) as fp:
1221                         fdroidserverid = fp.read().rstrip()
1222                     txt = "* build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + '\n' \
1223                           + '* fdroidserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
1224                           + fdroidserverid + ' ' + fdroidserverid + ']\n\n'
1225                     if options.onserver:
1226                         txt += '* buildserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
1227                                + buildserverid + ' ' + buildserverid + ']\n\n'
1228                     txt += tools_version_log + '\n\n'
1229                     txt += '== Build Log ==\n\n' + wikilog
1230                     newpage.save(txt, summary='Build log')
1231                     # Redirect from /lastbuild to the most recent build log
1232                     newpage = site.Pages[appid + '/lastbuild']
1233                     newpage.save('#REDIRECT [[' + lastbuildpage + ']]', summary='Update redirect')
1234                 except:
1235                     logging.error("Error while attempting to publish build log")
1236
1237     for app in build_succeeded:
1238         logging.info("success: %s" % (app.id))
1239
1240     if not options.verbose:
1241         for fa in failed_apps:
1242             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
1243
1244     # perform a drozer scan of all successful builds
1245     if options.dscanner and build_succeeded:
1246         from .dscanner import DockerDriver
1247
1248         docker = DockerDriver()
1249
1250         try:
1251             for app in build_succeeded:
1252
1253                 logging.info("Need to sign the app before we can install it.")
1254                 subprocess.call("fdroid publish {0}".format(app.id), shell=True)
1255
1256                 apk_path = None
1257
1258                 for f in os.listdir(repo_dir):
1259                     if f.endswith('.apk') and f.startswith(app.id):
1260                         apk_path = os.path.join(repo_dir, f)
1261                         break
1262
1263                 if not apk_path:
1264                     raise Exception("No signed APK found at path: {0}".format(apk_path))
1265
1266                 if not os.path.isdir(repo_dir):
1267                     exit(1)
1268
1269                 logging.info("Performing Drozer scan on {0}.".format(app))
1270                 docker.perform_drozer_scan(apk_path, app.id, repo_dir)
1271         except Exception as e:
1272             logging.error(str(e))
1273             logging.error("An exception happened. Making sure to clean up")
1274         else:
1275             logging.info("Scan succeeded.")
1276
1277         logging.info("Cleaning up after ourselves.")
1278         docker.clean()
1279
1280     logging.info("Finished.")
1281     if len(build_succeeded) > 0:
1282         logging.info(str(len(build_succeeded)) + ' builds succeeded')
1283     if len(failed_apps) > 0:
1284         logging.info(str(len(failed_apps)) + ' builds failed')
1285
1286     sys.exit(0)
1287
1288
1289 if __name__ == "__main__":
1290     main()