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