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