chiark / gitweb /
If given --verbose, don't print the whole error log again
[fdroidserver.git] / fdroidserver / build.py
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3 #
4 # build.py - part of the FDroid server tools
5 # Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
6 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
7 #
8 # This program is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU Affero General Public License as published by
10 # the Free Software Foundation, either version 3 of the License, or
11 # (at your option) any later version.
12 #
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 # GNU Affero General Public License for more details.
17 #
18 # You should have received a copy of the GNU Affero General Public License
19 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
20
21 import sys
22 import os
23 import shutil
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 optparse import OptionParser, OptionError
32 import logging
33 import multiprocessing
34
35 import common, metadata
36 from common import BuildException, VCSException, FDroidPopen, SilentPopen
37
38 def get_builder_vm_id():
39     vd = os.path.join('builder', '.vagrant')
40     if os.path.isdir(vd):
41         # Vagrant 1.2 (and maybe 1.1?) it's a directory tree...
42         with open(os.path.join(vd, 'machines', 'default', 'virtualbox', 'id')) as vf:
43             id = vf.read()
44         return id
45     else:
46         # Vagrant 1.0 - it's a json file...
47         with open(os.path.join('builder', '.vagrant')) as vf:
48             v = json.load(vf)
49         return v['active']['default']
50
51 def got_valid_builder_vm():
52     """Returns True if we have a valid-looking builder vm
53     """
54     if not os.path.exists(os.path.join('builder', 'Vagrantfile')):
55         return False
56     vd = os.path.join('builder', '.vagrant')
57     if not os.path.exists(vd):
58         return False
59     if not os.path.isdir(vd):
60         # Vagrant 1.0 - if the directory is there, it's valid...
61         return True
62     # Vagrant 1.2 - the directory can exist, but the id can be missing...
63     if not os.path.exists(os.path.join(vd, 'machines', 'default', 'virtualbox', 'id')):
64         return False
65     return True
66
67
68 def vagrant(params, cwd=None, printout=False):
69     """Run vagrant.
70
71     :param: list of parameters to pass to vagrant
72     :cwd: directory to run in, or None for current directory
73     :returns: (ret, out) where ret is the return code, and out
74                is the stdout (and stderr) from vagrant
75     """
76     p = FDroidPopen(['vagrant'] + params, cwd=cwd)
77     return (p.returncode, p.stdout)
78
79
80 # Note that 'force' here also implies test mode.
81 def build_server(app, thisbuild, vcs, build_dir, output_dir, force):
82     """Do a build on the build server."""
83
84     import ssh
85     if options.verbose:
86         logging.getLogger("ssh").setLevel(logging.DEBUG)
87     else:
88         logging.getLogger("ssh").setLevel(logging.WARN)
89
90     # Reset existing builder machine to a clean state if possible.
91     vm_ok = False
92     if not options.resetserver:
93         logging.info("Checking for valid existing build server")
94
95         if got_valid_builder_vm():
96             logging.info("...VM is present")
97             p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(), 'list', '--details'], cwd='builder')
98             if 'fdroidclean' in p.stdout:
99                 logging.info("...snapshot exists - resetting build server to clean state")
100                 retcode, output = vagrant(['status'], cwd='builder')
101
102                 if 'running' in output:
103                     logging.info("...suspending")
104                     vagrant(['suspend'], cwd='builder')
105                     logging.info("...waiting a sec...")
106                     time.sleep(10)
107                 p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(), 'restore', 'fdroidclean'],
108                     cwd='builder')
109
110                 if p.returncode == 0:
111                     logging.info("...reset to snapshot - server is valid")
112                     retcode, output = vagrant(['up'], cwd='builder')
113                     if retcode != 0:
114                         raise BuildException("Failed to start build server")
115                     logging.info("...waiting a sec...")
116                     time.sleep(10)
117                     vm_ok = True
118                 else:
119                     logging.info("...failed to reset to snapshot")
120             else:
121                 logging.info("...snapshot doesn't exist - VBoxManage snapshot list:\n" + output)
122
123     # If we can't use the existing machine for any reason, make a
124     # new one from scratch.
125     if not vm_ok:
126         if os.path.exists('builder'):
127             logging.info("Removing broken/incomplete/unwanted build server")
128             vagrant(['destroy', '-f'], cwd='builder')
129             shutil.rmtree('builder')
130         os.mkdir('builder')
131
132         p = subprocess.Popen('vagrant --version', shell=True, stdout=subprocess.PIPE)
133         vver = p.communicate()[0]
134         if vver.startswith('Vagrant version 1.2'):
135             with open('builder/Vagrantfile', 'w') as vf:
136                 vf.write('Vagrant.configure("2") do |config|\n')
137                 vf.write('config.vm.box = "buildserver"\n')
138                 vf.write('end\n')
139         else:
140             with open('builder/Vagrantfile', 'w') as vf:
141                 vf.write('Vagrant::Config.run do |config|\n')
142                 vf.write('config.vm.box = "buildserver"\n')
143                 vf.write('end\n')
144
145         logging.info("Starting new build server")
146         retcode, _ = vagrant(['up'], cwd='builder')
147         if retcode != 0:
148             raise BuildException("Failed to start build server")
149
150         # Open SSH connection to make sure it's working and ready...
151         logging.info("Connecting to virtual machine...")
152         if subprocess.call('vagrant ssh-config >sshconfig',
153                 cwd='builder', shell=True) != 0:
154             raise BuildException("Error getting ssh config")
155         vagranthost = 'default' # Host in ssh config file
156         sshconfig = ssh.SSHConfig()
157         sshf = open('builder/sshconfig', 'r')
158         sshconfig.parse(sshf)
159         sshf.close()
160         sshconfig = sshconfig.lookup(vagranthost)
161         sshs = ssh.SSHClient()
162         sshs.set_missing_host_key_policy(ssh.AutoAddPolicy())
163         idfile = sshconfig['identityfile']
164         if idfile.startswith('"') and idfile.endswith('"'):
165             idfile = idfile[1:-1]
166         sshs.connect(sshconfig['hostname'], username=sshconfig['user'],
167             port=int(sshconfig['port']), timeout=300, look_for_keys=False,
168             key_filename=idfile)
169         sshs.close()
170
171         logging.info("Saving clean state of new build server")
172         retcode, _ = vagrant(['suspend'], cwd='builder')
173         if retcode != 0:
174             raise BuildException("Failed to suspend build server")
175         logging.info("...waiting a sec...")
176         time.sleep(10)
177         p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(), 'take', 'fdroidclean'],
178                 cwd='builder')
179         if p.returncode != 0:
180             raise BuildException("Failed to take snapshot")
181         logging.info("...waiting a sec...")
182         time.sleep(10)
183         logging.info("Restarting new build server")
184         retcode, _ = vagrant(['up'], cwd='builder')
185         if retcode != 0:
186             raise BuildException("Failed to start build server")
187         logging.info("...waiting a sec...")
188         time.sleep(10)
189         # Make sure it worked...
190         p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(), 'list', '--details'],
191             cwd='builder')
192         if 'fdroidclean' not in p.stdout:
193             raise BuildException("Failed to take snapshot.")
194
195     try:
196
197         # Get SSH configuration settings for us to connect...
198         logging.info("Getting ssh configuration...")
199         subprocess.call('vagrant ssh-config >sshconfig',
200                 cwd='builder', shell=True)
201         vagranthost = 'default' # Host in ssh config file
202
203         # Load and parse the SSH config...
204         sshconfig = ssh.SSHConfig()
205         sshf = open('builder/sshconfig', 'r')
206         sshconfig.parse(sshf)
207         sshf.close()
208         sshconfig = sshconfig.lookup(vagranthost)
209
210         # Open SSH connection...
211         logging.info("Connecting to virtual machine...")
212         sshs = ssh.SSHClient()
213         sshs.set_missing_host_key_policy(ssh.AutoAddPolicy())
214         idfile = sshconfig['identityfile']
215         if idfile.startswith('"') and idfile.endswith('"'):
216             idfile = idfile[1:-1]
217         sshs.connect(sshconfig['hostname'], username=sshconfig['user'],
218             port=int(sshconfig['port']), timeout=300, look_for_keys=False,
219             key_filename=idfile)
220
221         # Get an SFTP connection...
222         ftp = sshs.open_sftp()
223         ftp.get_channel().settimeout(15)
224
225         # Put all the necessary files in place...
226         ftp.chdir('/home/vagrant')
227
228         # Helper to copy the contents of a directory to the server...
229         def send_dir(path):
230             root = os.path.dirname(path)
231             main = os.path.basename(path)
232             ftp.mkdir(main)
233             for r, d, f in os.walk(path):
234                 rr = os.path.relpath(r, root)
235                 ftp.chdir(rr)
236                 for dd in d:
237                     ftp.mkdir(dd)
238                 for ff in f:
239                     lfile = os.path.join(root, rr, ff)
240                     if not os.path.islink(lfile):
241                         ftp.put(lfile, ff)
242                         ftp.chmod(ff, os.stat(lfile).st_mode)
243                 for i in range(len(rr.split('/'))):
244                     ftp.chdir('..')
245             ftp.chdir('..')
246
247         logging.info("Preparing server for build...")
248         serverpath = os.path.abspath(os.path.dirname(__file__))
249         ftp.put(os.path.join(serverpath, 'build.py'), 'build.py')
250         ftp.put(os.path.join(serverpath, 'common.py'), 'common.py')
251         ftp.put(os.path.join(serverpath, 'metadata.py'), 'metadata.py')
252         ftp.put(os.path.join(serverpath, '..', 'buildserver',
253             'config.buildserver.py'), 'config.py')
254         ftp.chmod('config.py', 0o600)
255
256         # Copy the metadata - just the file for this app...
257         ftp.mkdir('metadata')
258         ftp.mkdir('srclibs')
259         ftp.chdir('metadata')
260         ftp.put(os.path.join('metadata', app['id'] + '.txt'),
261                 app['id'] + '.txt')
262         # And patches if there are any...
263         if os.path.exists(os.path.join('metadata', app['id'])):
264             send_dir(os.path.join('metadata', app['id']))
265
266         ftp.chdir('/home/vagrant')
267         # Create the build directory...
268         ftp.mkdir('build')
269         ftp.chdir('build')
270         ftp.mkdir('extlib')
271         ftp.mkdir('srclib')
272         # Copy any extlibs that are required...
273         if 'extlibs' in thisbuild:
274             ftp.chdir('/home/vagrant/build/extlib')
275             for lib in thisbuild['extlibs']:
276                 lib = lib.strip()
277                 libsrc = os.path.join('build/extlib', lib)
278                 if not os.path.exists(libsrc):
279                     raise BuildException("Missing extlib {0}".format(libsrc))
280                 lp = lib.split('/')
281                 for d in lp[:-1]:
282                     if d not in ftp.listdir():
283                         ftp.mkdir(d)
284                     ftp.chdir(d)
285                 ftp.put(libsrc, lp[-1])
286                 for _ in lp[:-1]:
287                     ftp.chdir('..')
288         # Copy any srclibs that are required...
289         srclibpaths = []
290         if 'srclibs' in thisbuild:
291             for lib in thisbuild['srclibs']:
292                 srclibpaths.append(common.getsrclib(lib, 'build/srclib', srclibpaths,
293                     basepath=True, prepare=False))
294
295         # If one was used for the main source, add that too.
296         basesrclib = vcs.getsrclib()
297         if basesrclib:
298             srclibpaths.append(basesrclib)
299         for name, number, lib in srclibpaths:
300             logging.info("Sending srclib '%s'" % lib)
301             ftp.chdir('/home/vagrant/build/srclib')
302             if not os.path.exists(lib):
303                 raise BuildException("Missing srclib directory '" + lib + "'")
304             fv = '.fdroidvcs-' + name
305             ftp.put(os.path.join('build/srclib', fv), fv)
306             send_dir(lib)
307             # Copy the metadata file too...
308             ftp.chdir('/home/vagrant/srclibs')
309             ftp.put(os.path.join('srclibs', name + '.txt'),
310                     name + '.txt')
311         # Copy the main app source code
312         # (no need if it's a srclib)
313         if (not basesrclib) and os.path.exists(build_dir):
314             ftp.chdir('/home/vagrant/build')
315             fv = '.fdroidvcs-' + app['id']
316             ftp.put(os.path.join('build', fv), fv)
317             send_dir(build_dir)
318
319         # Execute the build script...
320         logging.info("Starting build...")
321         chan = sshs.get_transport().open_session()
322         chan.get_pty()
323         cmdline = 'python build.py --on-server'
324         if force:
325             cmdline += ' --force --test'
326         if options.verbose:
327             cmdline += ' --verbose'
328         cmdline += " %s:%s" % (app['id'], thisbuild['vercode'])
329         chan.exec_command('bash -c ". ~/.bsenv && ' + cmdline + '"')
330         output = ''
331         while not chan.exit_status_ready():
332             while chan.recv_ready():
333                 output += chan.recv(1024)
334             time.sleep(0.1)
335         logging.info("...getting exit status")
336         returncode = chan.recv_exit_status()
337         while True:
338             get = chan.recv(1024)
339             if len(get) == 0:
340                 break
341             output += get
342         if returncode != 0:
343             raise BuildException("Build.py failed on server for %s:%s" % (app['id'], thisbuild['version']), output)
344
345         # Retrieve the built files...
346         logging.info("Retrieving build output...")
347         if force:
348             ftp.chdir('/home/vagrant/tmp')
349         else:
350             ftp.chdir('/home/vagrant/unsigned')
351         apkfile = common.getapkname(app,thisbuild)
352         tarball = common.getsrcname(app,thisbuild)
353         try:
354             ftp.get(apkfile, os.path.join(output_dir, apkfile))
355             if not options.notarball:
356                 ftp.get(tarball, os.path.join(output_dir, tarball))
357         except:
358             raise BuildException("Build failed for %s:%s - missing output files" % (app['id'], thisbuild['version']), output)
359         ftp.close()
360
361     finally:
362
363         # Suspend the build server.
364         logging.info("Suspending build server")
365         subprocess.call(['vagrant', 'suspend'], cwd='builder')
366
367 def adapt_gradle(build_dir):
368     for root, dirs, files in os.walk(build_dir):
369         if 'build.gradle' in files:
370             path = os.path.join(root, 'build.gradle')
371             logging.info("Adapting build.gradle at %s" % path)
372
373             FDroidPopen(['sed', '-i',
374                     r's@buildToolsVersion\([ =]*\)["\'][0-9\.]*["\']@buildToolsVersion\1"'
375                     + config['build_tools'] + '"@g', path])
376
377
378 def build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver):
379     """Do a build locally."""
380
381     if thisbuild.get('buildjni') not in (None, ['no']):
382         if not config['ndk_path']:
383             logging.critical("$ANDROID_NDK is not set!")
384             sys.exit(3)
385         elif not os.path.isdir(config['sdk_path']):
386             logging.critical("$ANDROID_NDK points to a non-existing directory!")
387             sys.exit(3)
388
389     # Prepare the source code...
390     root_dir, srclibpaths = common.prepare_source(vcs, app, thisbuild,
391             build_dir, srclib_dir, extlib_dir, onserver)
392
393     # We need to clean via the build tool in case the binary dirs are
394     # different from the default ones
395     p = None
396     if thisbuild['type'] == 'maven':
397         logging.info("Cleaning Maven project...")
398         cmd = [config['mvn3'], 'clean', '-Dandroid.sdk.path=' + config['sdk_path']]
399
400         if '@' in thisbuild['maven']:
401             maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@',1)[1])
402             maven_dir = os.path.normpath(maven_dir)
403         else:
404             maven_dir = root_dir
405
406         p = FDroidPopen(cmd, cwd=maven_dir)
407
408     elif thisbuild['type'] == 'gradle':
409
410         logging.info("Cleaning Gradle project...")
411         cmd = [config['gradle'], 'clean']
412
413         if '@' in thisbuild['gradle']:
414             gradle_dir = os.path.join(root_dir, thisbuild['gradle'].split('@',1)[1])
415             gradle_dir = os.path.normpath(gradle_dir)
416         else:
417             gradle_dir = root_dir
418
419         adapt_gradle(build_dir)
420         for name, number, libpath in srclibpaths:
421             adapt_gradle(libpath)
422
423         p = FDroidPopen(cmd, cwd=gradle_dir)
424
425     elif thisbuild['type'] == 'kivy':
426         pass
427
428     elif thisbuild['type'] == 'ant':
429         logging.info("Cleaning Ant project...")
430         p = FDroidPopen(['ant', 'clean'], cwd=root_dir)
431
432     if p is not None and p.returncode != 0:
433         raise BuildException("Error cleaning %s:%s" %
434                 (app['id'], thisbuild['version']), p.stdout)
435
436     logging.info("Getting rid of Gradle wrapper binaries...")
437     for root, dirs, files in os.walk(build_dir):
438         # Don't remove possibly necessary 'gradle' dirs if 'gradlew' is not there
439         if 'gradlew' in files:
440             os.remove(os.path.join(root, 'gradlew'))
441             if 'gradlew.bat' in files:
442                 os.remove(os.path.join(root, 'gradlew.bat'))
443             if 'gradle' in dirs:
444                 shutil.rmtree(os.path.join(root, 'gradle'))
445
446     if not options.skipscan:
447         # Scan before building...
448         logging.info("Scanning source for common problems...")
449         buildprobs = common.scan_source(build_dir, root_dir, thisbuild)
450         if len(buildprobs) > 0:
451             logging.warn('Scanner found %d problems:' % len(buildprobs))
452             for problem in buildprobs:
453                 logging.info('    %s' % problem)
454             if not force:
455                 raise BuildException("Can't build due to " +
456                     str(len(buildprobs)) + " scanned problems")
457
458     if not options.notarball:
459         # Build the source tarball right before we build the release...
460         logging.info("Creating source tarball...")
461         tarname = common.getsrcname(app,thisbuild)
462         tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
463         def tarexc(f):
464             return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
465         tarball.add(build_dir, tarname, exclude=tarexc)
466         tarball.close()
467
468     # Run a build command if one is required...
469     if 'build' in thisbuild:
470         cmd = common.replace_config_vars(thisbuild['build'])
471         # Substitute source library paths into commands...
472         for name, number, libpath in srclibpaths:
473             libpath = os.path.relpath(libpath, root_dir)
474             cmd = cmd.replace('$$' + name + '$$', libpath)
475         logging.info("Running 'build' commands in %s" % root_dir)
476
477         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
478
479         if p.returncode != 0:
480             raise BuildException("Error running build command for %s:%s" %
481                     (app['id'], thisbuild['version']), p.stdout)
482
483     # Build native stuff if required...
484     if thisbuild.get('buildjni') not in (None, ['no']):
485         logging.info("Building native libraries...")
486         jni_components = thisbuild.get('buildjni')
487         if jni_components == ['yes']:
488             jni_components = ['']
489         jobs = multiprocessing.cpu_count()
490         ndkbuild = os.path.join(config['ndk_path'], "ndk-build")
491         cmd = [ndkbuild, "-j"+str(jobs)]
492         for d in jni_components:
493             logging.info("Building native code in '%s'" % d)
494             manifest = root_dir + '/' + d + '/AndroidManifest.xml'
495             if os.path.exists(manifest):
496                 # Read and write the whole AM.xml to fix newlines and avoid
497                 # the ndk r8c or later 'wordlist' errors. The outcome of this
498                 # under gnu/linux is the same as when using tools like
499                 # dos2unix, but the native python way is faster and will
500                 # work in non-unix systems.
501                 manifest_text = open(manifest, 'U').read()
502                 open(manifest, 'w').write(manifest_text)
503                 # In case the AM.xml read was big, free the memory
504                 del manifest_text
505             p = FDroidPopen(cmd, cwd=os.path.join(root_dir,d))
506             if p.returncode != 0:
507                 raise BuildException("NDK build failed for %s:%s" % (app['id'], thisbuild['version']), p.stdout)
508
509     p = None
510     # Build the release...
511     if thisbuild['type'] == 'maven':
512         logging.info("Building Maven project...")
513
514         if '@' in thisbuild['maven']:
515             maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@',1)[1])
516         else:
517             maven_dir = root_dir
518
519         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
520                 '-Dandroid.sign.debug=false', '-Dmaven.test.skip=true',
521                 '-Dandroid.release=true', 'package']
522         if 'target' in thisbuild:
523             target = thisbuild["target"].split('-')[1]
524             FDroidPopen(['sed', '-i',
525                     's@<platform>[0-9]*</platform>@<platform>'+target+'</platform>@g',
526                     'pom.xml'], cwd=root_dir)
527             if '@' in thisbuild['maven']:
528                 FDroidPopen(['sed', '-i',
529                         's@<platform>[0-9]*</platform>@<platform>'+target+'</platform>@g',
530                         'pom.xml'], cwd=maven_dir)
531
532         if 'mvnflags' in thisbuild:
533             mvncmd += thisbuild['mvnflags']
534
535         p = FDroidPopen(mvncmd, cwd=maven_dir)
536
537         bindir = os.path.join(root_dir, 'target')
538
539     elif thisbuild['type'] == 'kivy':
540         logging.info("Building Kivy project...")
541
542         spec = os.path.join(root_dir, 'buildozer.spec')
543         if not os.path.exists(spec):
544             raise BuildException("Expected to find buildozer-compatible spec at {0}"
545                     .format(spec))
546
547         defaults = {'orientation': 'landscape', 'icon': '',
548                 'permissions': '', 'android.api': "18"}
549         bconfig = ConfigParser(defaults, allow_no_value=True)
550         bconfig.read(spec)
551
552         distdir = 'python-for-android/dist/fdroid'
553         if os.path.exists(distdir):
554             shutil.rmtree(distdir)
555
556         modules = bconfig.get('app', 'requirements').split(',')
557
558         cmd = 'ANDROIDSDK=' + config['sdk_path']
559         cmd += ' ANDROIDNDK=' + config['ndk_path']
560         cmd += ' ANDROIDNDKVER=r9'
561         cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
562         cmd += ' VIRTUALENV=virtualenv'
563         cmd += ' ./distribute.sh'
564         cmd += ' -m ' + "'" + ' '.join(modules) + "'"
565         cmd += ' -d fdroid'
566         p = FDroidPopen(cmd, cwd='python-for-android', shell=True)
567         if p.returncode != 0:
568             raise BuildException("Distribute build failed")
569
570         cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
571         if cid != app['id']:
572             raise BuildException("Package ID mismatch between metadata and spec")
573
574         orientation = bconfig.get('app', 'orientation', 'landscape')
575         if orientation == 'all':
576             orientation = 'sensor'
577
578         cmd = ['./build.py'
579                 '--dir', root_dir,
580                 '--name', bconfig.get('app', 'title'),
581                 '--package', app['id'],
582                 '--version', bconfig.get('app', 'version'),
583                 '--orientation', orientation,
584                 ]
585
586         perms = bconfig.get('app', 'permissions')
587         for perm in perms.split(','):
588             cmd.extend(['--permission', perm])
589
590         if config.get('app', 'fullscreen') == 0:
591             cmd.append('--window')
592
593         icon = bconfig.get('app', 'icon.filename')
594         if icon:
595             cmd.extend(['--icon', os.path.join(root_dir, icon)])
596
597         cmd.append('release')
598         p = FDroidPopen(cmd, cwd=distdir)
599
600     elif thisbuild['type'] == 'gradle':
601         logging.info("Building Gradle project...")
602         if '@' in thisbuild['gradle']:
603             flavours = thisbuild['gradle'].split('@')[0].split(',')
604             gradle_dir = thisbuild['gradle'].split('@')[1]
605             gradle_dir = os.path.join(root_dir, gradle_dir)
606         else:
607             flavours = thisbuild['gradle'].split(',')
608             gradle_dir = root_dir
609
610         if len(flavours) == 1 and flavours[0] in ['main', 'yes', '']:
611             flavours[0] = ''
612
613         commands = [config['gradle']]
614         if 'preassemble' in thisbuild:
615             commands += thisbuild['preassemble'].split()
616
617         flavours_cmd = ''.join(flavours)
618         if flavours_cmd:
619             flavours_cmd = flavours_cmd[0].upper() + flavours_cmd[1:]
620
621         commands += ['assemble'+flavours_cmd+'Release']
622
623         p = FDroidPopen(commands, cwd=gradle_dir)
624
625     elif thisbuild['type'] == 'ant':
626         logging.info("Building Ant project...")
627         cmd = ['ant']
628         if 'antcommand' in thisbuild:
629             cmd += [thisbuild['antcommand']]
630         else:
631             cmd += ['release']
632         p = FDroidPopen(cmd, cwd=root_dir)
633
634         bindir = os.path.join(root_dir, 'bin')
635
636     if p is not None and p.returncode != 0:
637         raise BuildException("Build failed for %s:%s" % (app['id'], thisbuild['version']), p.stdout)
638     logging.info("Successfully built version " + thisbuild['version'] + ' of ' + app['id'])
639
640     if thisbuild['type'] == 'maven':
641         stdout_apk = '\n'.join([
642             line for line in p.stdout.splitlines() if any(a in line for a in ('.apk','.ap_'))])
643         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
644                 stdout_apk, re.S|re.M)
645         if not m:
646             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
647                     stdout_apk, re.S|re.M)
648         if not m:
649             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
650                     stdout_apk, re.S|re.M)
651         if not m:
652             raise BuildException('Failed to find output')
653         src = m.group(1)
654         src = os.path.join(bindir, src) + '.apk'
655     elif thisbuild['type'] == 'kivy':
656         src = 'python-for-android/dist/default/bin/{0}-{1}-release.apk'.format(
657                 bconfig.get('app', 'title'), bconfig.get('app', 'version'))
658     elif thisbuild['type'] == 'gradle':
659         dd = build_dir
660         if 'subdir' in thisbuild:
661             dd = os.path.join(dd, thisbuild['subdir'])
662         if len(flavours) == 1 and flavours[0] == '':
663             name = '-'.join([os.path.basename(dd), 'release', 'unsigned'])
664         else:
665             name = '-'.join([os.path.basename(dd), '-'.join(flavours), 'release', 'unsigned'])
666         src = os.path.join(dd, 'build', 'apk', name+'.apk')
667     elif thisbuild['type'] == 'ant':
668         stdout_apk = '\n'.join([
669             line for line in p.stdout.splitlines() if '.apk' in line])
670         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
671             re.S|re.M).group(1)
672         src = os.path.join(bindir, src)
673     elif thisbuild['type'] == 'raw':
674         src = os.path.join(root_dir, thisbuild['output'])
675         src = os.path.normpath(src)
676
677     # Make sure it's not debuggable...
678     if common.isApkDebuggable(src, config):
679         raise BuildException("APK is debuggable")
680
681     # By way of a sanity check, make sure the version and version
682     # code in our new apk match what we expect...
683     logging.info("Checking " + src)
684     if not os.path.exists(src):
685         raise BuildException("Unsigned apk is not at expected location of " + src)
686
687     p = SilentPopen([os.path.join(config['sdk_path'],
688         'build-tools', config['build_tools'], 'aapt'),
689         'dump', 'badging', src])
690
691     vercode = None
692     version = None
693     foundid = None
694     for line in p.stdout.splitlines():
695         if line.startswith("package:"):
696             pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
697             m = pat.match(line)
698             if m:
699                 foundid = m.group(1)
700             pat = re.compile(".*versionCode='([0-9]*)'.*")
701             m = pat.match(line)
702             if m:
703                 vercode = m.group(1)
704             pat = re.compile(".*versionName='([^']*)'.*")
705             m = pat.match(line)
706             if m:
707                 version = m.group(1)
708
709     if thisbuild['novcheck']:
710         vercode = thisbuild['vercode']
711         version = thisbuild['version']
712     if not version or not vercode:
713         raise BuildException("Could not find version information in build in output")
714     if not foundid:
715         raise BuildException("Could not find package ID in output")
716     if foundid != app['id']:
717         raise BuildException("Wrong package ID - build " + foundid + " but expected " + app['id'])
718
719     # Some apps (e.g. Timeriffic) have had the bonkers idea of
720     # including the entire changelog in the version number. Remove
721     # it so we can compare. (TODO: might be better to remove it
722     # before we compile, in fact)
723     index = version.find(" //")
724     if index != -1:
725         version = version[:index]
726
727     if (version != thisbuild['version'] or
728             vercode != thisbuild['vercode']):
729         raise BuildException(("Unexpected version/version code in output;"
730                              " APK: '%s' / '%s', "
731                              " Expected: '%s' / '%s'")
732                              % (version, str(vercode), thisbuild['version'], str(thisbuild['vercode']))
733                             )
734
735     # Copy the unsigned apk to our destination directory for further
736     # processing (by publish.py)...
737     dest = os.path.join(output_dir, common.getapkname(app,thisbuild))
738     shutil.copyfile(src, dest)
739
740     # Move the source tarball into the output directory...
741     if output_dir != tmp_dir and not options.notarball:
742         shutil.move(os.path.join(tmp_dir, tarname),
743             os.path.join(output_dir, tarname))
744
745
746 def trybuild(app, thisbuild, build_dir, output_dir, also_check_dir, srclib_dir, extlib_dir,
747         tmp_dir, repo_dir, vcs, test, server, force, onserver):
748     """
749     Build a particular version of an application, if it needs building.
750
751     :param output_dir: The directory where the build output will go. Usually
752        this is the 'unsigned' directory.
753     :param repo_dir: The repo directory - used for checking if the build is
754        necessary.
755     :paaram also_check_dir: An additional location for checking if the build
756        is necessary (usually the archive repo)
757     :param test: True if building in test mode, in which case the build will
758        always happen, even if the output already exists. In test mode, the
759        output directory should be a temporary location, not any of the real
760        ones.
761
762     :returns: True if the build was done, False if it wasn't necessary.
763     """
764
765     dest_apk = common.getapkname(app, thisbuild)
766
767     dest = os.path.join(output_dir, dest_apk)
768     dest_repo = os.path.join(repo_dir, dest_apk)
769
770     if not test:
771         if os.path.exists(dest) or os.path.exists(dest_repo):
772             return False
773
774         if also_check_dir:
775             dest_also = os.path.join(also_check_dir, dest_apk)
776             if os.path.exists(dest_also):
777                 return False
778
779     if 'disable' in thisbuild:
780         return False
781
782     logging.info("Building version " + thisbuild['version'] + ' of ' + app['id'])
783
784     if server:
785         # When using server mode, still keep a local cache of the repo, by
786         # grabbing the source now.
787         vcs.gotorevision(thisbuild['commit'])
788
789         build_server(app, thisbuild, vcs, build_dir, output_dir, force)
790     else:
791         build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver)
792     return True
793
794
795 def parse_commandline():
796     """Parse the command line. Returns options, args."""
797
798     parser = OptionParser(usage="Usage: %prog [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
799     parser.add_option("-v", "--verbose", action="store_true", default=False,
800                       help="Spew out even more information than normal")
801     parser.add_option("-q", "--quiet", action="store_true", default=False,
802                       help="Restrict output to warnings and errors")
803     parser.add_option("-l", "--latest", action="store_true", default=False,
804                       help="Build only the latest version of each package")
805     parser.add_option("-s", "--stop", action="store_true", default=False,
806                       help="Make the build stop on exceptions")
807     parser.add_option("-t", "--test", action="store_true", default=False,
808                       help="Test mode - put output in the tmp directory only, and always build, even if the output already exists.")
809     parser.add_option("--server", action="store_true", default=False,
810                       help="Use build server")
811     parser.add_option("--resetserver", action="store_true", default=False,
812                       help="Reset and create a brand new build server, even if the existing one appears to be ok.")
813     parser.add_option("--on-server", dest="onserver", action="store_true", default=False,
814                       help="Specify that we're running on the build server")
815     parser.add_option("--skip-scan", dest="skipscan", action="store_true", default=False,
816                       help="Skip scanning the source code for binaries and other problems")
817     parser.add_option("--no-tarball", dest="notarball", action="store_true", default=False,
818                       help="Don't create a source tarball, useful when testing a build")
819     parser.add_option("-f", "--force", action="store_true", default=False,
820                       help="Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode.")
821     parser.add_option("-a", "--all", action="store_true", default=False,
822                       help="Build all applications available")
823     parser.add_option("-w", "--wiki", default=False, action="store_true",
824                       help="Update the wiki")
825     options, args = parser.parse_args()
826
827     # Force --stop with --on-server to get correct exit code
828     if options.onserver:
829         options.stop = True
830
831     if options.force and not options.test:
832         raise OptionError("Force is only allowed in test mode", "force")
833
834     return options, args
835
836 options = None
837 config = None
838
839 def main():
840
841     global options, config
842
843     options, args = parse_commandline()
844     if not args and not options.all:
845         raise OptionError("If you really want to build all the apps, use --all", "all")
846
847     config = common.read_config(options)
848
849     if config['build_server_always']:
850         options.server = True
851     if options.resetserver and not options.server:
852         raise OptionError("Using --resetserver without --server makes no sense", "resetserver")
853
854     log_dir = 'logs'
855     if not os.path.isdir(log_dir):
856         logging.info("Creating log directory")
857         os.makedirs(log_dir)
858
859     tmp_dir = 'tmp'
860     if not os.path.isdir(tmp_dir):
861         logging.info("Creating temporary directory")
862         os.makedirs(tmp_dir)
863
864     if options.test:
865         output_dir = tmp_dir
866     else:
867         output_dir = 'unsigned'
868         if not os.path.isdir(output_dir):
869             logging.info("Creating output directory")
870             os.makedirs(output_dir)
871
872     if config['archive_older'] != 0:
873         also_check_dir = 'archive'
874     else:
875         also_check_dir = None
876
877     repo_dir = 'repo'
878
879     build_dir = 'build'
880     if not os.path.isdir(build_dir):
881         logging.info("Creating build directory")
882         os.makedirs(build_dir)
883     srclib_dir = os.path.join(build_dir, 'srclib')
884     extlib_dir = os.path.join(build_dir, 'extlib')
885
886     # Get all apps...
887     allapps = metadata.read_metadata(xref=not options.onserver)
888
889     apps = common.read_app_args(args, allapps, True)
890     apps = [app for app in apps if (options.force or not app['Disabled']) and
891             len(app['Repo Type']) > 0 and len(app['builds']) > 0]
892
893     if len(apps) == 0:
894         raise Exception("No apps to process.")
895
896     if options.latest:
897         for app in apps:
898             for build in reversed(app['builds']):
899                 if 'disable' in build:
900                     continue
901                 app['builds'] = [ build ]
902                 break
903
904     if options.wiki:
905         import mwclient
906         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
907                 path=config['wiki_path'])
908         site.login(config['wiki_user'], config['wiki_password'])
909
910     # Build applications...
911     failed_apps = {}
912     build_succeeded = []
913     for app in apps:
914
915         first = True
916
917         for thisbuild in app['builds']:
918             wikilog = None
919             try:
920
921                 # For the first build of a particular app, we need to set up
922                 # the source repo. We can reuse it on subsequent builds, if
923                 # there are any.
924                 if first:
925                     if app['Repo Type'] == 'srclib':
926                         build_dir = os.path.join('build', 'srclib', app['Repo'])
927                     else:
928                         build_dir = os.path.join('build', app['id'])
929
930                     # Set up vcs interface and make sure we have the latest code...
931                     logging.debug("Getting {0} vcs interface for {1}".format(
932                             app['Repo Type'], app['Repo']))
933                     vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
934
935                     first = False
936
937                 logging.debug("Checking " + thisbuild['version'])
938                 if trybuild(app, thisbuild, build_dir, output_dir, also_check_dir,
939                         srclib_dir, extlib_dir, tmp_dir, repo_dir, vcs, options.test,
940                         options.server, options.force, options.onserver):
941                     build_succeeded.append(app)
942                     wikilog = "Build succeeded"
943             except BuildException as be:
944                 logfile = open(os.path.join(log_dir, app['id'] + '.log'), 'a+')
945                 logfile.write(str(be))
946                 logfile.close()
947                 reason = str(be).split('\n',1)[0] if options.verbose else str(be)
948                 print("Could not build app %s due to BuildException: %s" % (
949                     app['id'], reason))
950                 if options.stop:
951                     sys.exit(1)
952                 failed_apps[app['id']] = be
953                 wikilog = be.get_wikitext()
954             except VCSException as vcse:
955                 print("VCS error while building app %s: %s" % (app['id'], vcse))
956                 if options.stop:
957                     sys.exit(1)
958                 failed_apps[app['id']] = vcse
959                 wikilog = str(vcse)
960             except Exception as e:
961                 print("Could not build app %s due to unknown error: %s" % (app['id'], traceback.format_exc()))
962                 if options.stop:
963                     sys.exit(1)
964                 failed_apps[app['id']] = e
965                 wikilog = str(e)
966
967             if options.wiki and wikilog:
968                 try:
969                     newpage = site.Pages[app['id'] + '/lastbuild']
970                     txt = "Build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + "\n\n" + wikilog
971                     newpage.save(txt, summary='Build log')
972                 except:
973                     logging.info("Error while attempting to publish build log")
974
975     for app in build_succeeded:
976         logging.info("success: %s" % (app['id']))
977
978     if not options.verbose:
979         for fa in failed_apps:
980             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
981
982     logging.info("Finished.")
983     if len(build_succeeded) > 0:
984         logging.info(str(len(build_succeeded)) + ' builds succeeded')
985     if len(failed_apps) > 0:
986         logging.info(str(len(failed_apps)) + ' builds failed')
987
988     sys.exit(0)
989
990 if __name__ == "__main__":
991     main()
992