2 # -*- coding: utf-8 -*-
4 # build.py - part of the FDroid server tools
5 # Copyright (C) 2010-2014, Ciaran Gultnieks, ciaran@ciarang.com
6 # Copyright (C) 2013-2014 Daniel Martà <mvdan@mvdan.cc>
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.
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.
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/>.
31 from ConfigParser import ConfigParser
32 from argparse import ArgumentParser
33 from distutils.version import LooseVersion
40 from common import FDroidException, BuildException, VCSException, FDroidPopen, SdkToolsPopen
48 def get_builder_vm_id():
49 vd = os.path.join('builder', '.vagrant')
51 # Vagrant 1.2 (and maybe 1.1?) it's a directory tree...
52 with open(os.path.join(vd, 'machines', 'default',
53 'virtualbox', 'id')) as vf:
57 # Vagrant 1.0 - it's a json file...
58 with open(os.path.join('builder', '.vagrant')) as vf:
60 return v['active']['default']
63 def got_valid_builder_vm():
64 """Returns True if we have a valid-looking builder vm
66 if not os.path.exists(os.path.join('builder', 'Vagrantfile')):
68 vd = os.path.join('builder', '.vagrant')
69 if not os.path.exists(vd):
71 if not os.path.isdir(vd):
72 # Vagrant 1.0 - if the directory is there, it's valid...
74 # Vagrant 1.2 - the directory can exist, but the id can be missing...
75 if not os.path.exists(os.path.join(vd, 'machines', 'default',
81 def vagrant(params, cwd=None, printout=False):
82 """Run a vagrant command.
84 :param: list of parameters to pass to vagrant
85 :cwd: directory to run in, or None for current directory
86 :returns: (ret, out) where ret is the return code, and out
87 is the stdout (and stderr) from vagrant
89 p = FDroidPopen(['vagrant'] + params, cwd=cwd)
90 return (p.returncode, p.output)
93 def get_vagrant_sshinfo():
94 """Get ssh connection info for a vagrant VM
96 :returns: A dictionary containing 'hostname', 'port', 'user'
99 if subprocess.call('vagrant ssh-config >sshconfig',
100 cwd='builder', shell=True) != 0:
101 raise BuildException("Error getting ssh config")
102 vagranthost = 'default' # Host in ssh config file
103 sshconfig = paramiko.SSHConfig()
104 sshf = open(os.path.join('builder', 'sshconfig'), 'r')
105 sshconfig.parse(sshf)
107 sshconfig = sshconfig.lookup(vagranthost)
108 idfile = sshconfig['identityfile']
109 if isinstance(idfile, list):
111 elif idfile.startswith('"') and idfile.endswith('"'):
112 idfile = idfile[1:-1]
113 return {'hostname': sshconfig['hostname'],
114 'port': int(sshconfig['port']),
115 'user': sshconfig['user'],
119 def get_clean_vm(reset=False):
120 """Get a clean VM ready to do a buildserver build.
122 This might involve creating and starting a new virtual machine from
123 scratch, or it might be as simple (unless overridden by the reset
124 parameter) as re-using a snapshot created previously.
126 A BuildException will be raised if anything goes wrong.
128 :reset: True to force creating from scratch.
129 :returns: A dictionary containing 'hostname', 'port', 'user'
132 # Reset existing builder machine to a clean state if possible.
135 logging.info("Checking for valid existing build server")
137 if got_valid_builder_vm():
138 logging.info("...VM is present")
139 p = FDroidPopen(['VBoxManage', 'snapshot',
140 get_builder_vm_id(), 'list',
141 '--details'], cwd='builder')
142 if 'fdroidclean' in p.output:
143 logging.info("...snapshot exists - resetting build server to "
145 retcode, output = vagrant(['status'], cwd='builder')
147 if 'running' in output:
148 logging.info("...suspending")
149 vagrant(['suspend'], cwd='builder')
150 logging.info("...waiting a sec...")
152 p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
153 'restore', 'fdroidclean'],
156 if p.returncode == 0:
157 logging.info("...reset to snapshot - server is valid")
158 retcode, output = vagrant(['up'], cwd='builder')
160 raise BuildException("Failed to start build server")
161 logging.info("...waiting a sec...")
163 sshinfo = get_vagrant_sshinfo()
166 logging.info("...failed to reset to snapshot")
168 logging.info("...snapshot doesn't exist - "
169 "VBoxManage snapshot list:\n" + p.output)
171 # If we can't use the existing machine for any reason, make a
172 # new one from scratch.
174 if os.path.exists('builder'):
175 logging.info("Removing broken/incomplete/unwanted build server")
176 vagrant(['destroy', '-f'], cwd='builder')
177 shutil.rmtree('builder')
180 p = subprocess.Popen(['vagrant', '--version'],
181 stdout=subprocess.PIPE)
182 vver = p.communicate()[0]
183 with open(os.path.join('builder', 'Vagrantfile'), 'w') as vf:
184 if vver.startswith('Vagrant version 1.2'):
185 vf.write('Vagrant.configure("2") do |config|\n')
186 vf.write('config.vm.box = "buildserver"\n')
189 vf.write('Vagrant::Config.run do |config|\n')
190 vf.write('config.vm.box = "buildserver"\n')
193 logging.info("Starting new build server")
194 retcode, _ = vagrant(['up'], cwd='builder')
196 raise BuildException("Failed to start build server")
198 # Open SSH connection to make sure it's working and ready...
199 logging.info("Connecting to virtual machine...")
200 sshinfo = get_vagrant_sshinfo()
201 sshs = paramiko.SSHClient()
202 sshs.set_missing_host_key_policy(paramiko.AutoAddPolicy())
203 sshs.connect(sshinfo['hostname'], username=sshinfo['user'],
204 port=sshinfo['port'], timeout=300,
206 key_filename=sshinfo['idfile'])
209 logging.info("Saving clean state of new build server")
210 retcode, _ = vagrant(['suspend'], cwd='builder')
212 raise BuildException("Failed to suspend build server")
213 logging.info("...waiting a sec...")
215 p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
216 'take', 'fdroidclean'],
218 if p.returncode != 0:
219 raise BuildException("Failed to take snapshot")
220 logging.info("...waiting a sec...")
222 logging.info("Restarting new build server")
223 retcode, _ = vagrant(['up'], cwd='builder')
225 raise BuildException("Failed to start build server")
226 logging.info("...waiting a sec...")
228 # Make sure it worked...
229 p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
230 'list', '--details'],
232 if 'fdroidclean' not in p.output:
233 raise BuildException("Failed to take snapshot.")
239 """Release the VM previously started with get_clean_vm().
241 This should always be called.
243 logging.info("Suspending build server")
244 subprocess.call(['vagrant', 'suspend'], cwd='builder')
247 # Note that 'force' here also implies test mode.
248 def build_server(app, thisbuild, vcs, build_dir, output_dir, force):
249 """Do a build on the build server."""
254 raise BuildException("Paramiko is required to use the buildserver")
256 logging.getLogger("paramiko").setLevel(logging.DEBUG)
258 logging.getLogger("paramiko").setLevel(logging.WARN)
260 sshinfo = get_clean_vm()
264 # Open SSH connection...
265 logging.info("Connecting to virtual machine...")
266 sshs = paramiko.SSHClient()
267 sshs.set_missing_host_key_policy(paramiko.AutoAddPolicy())
268 sshs.connect(sshinfo['hostname'], username=sshinfo['user'],
269 port=sshinfo['port'], timeout=300,
270 look_for_keys=False, key_filename=sshinfo['idfile'])
272 homedir = '/home/' + sshinfo['user']
274 # Get an SFTP connection...
275 ftp = sshs.open_sftp()
276 ftp.get_channel().settimeout(15)
278 # Put all the necessary files in place...
281 # Helper to copy the contents of a directory to the server...
283 root = os.path.dirname(path)
284 main = os.path.basename(path)
286 for r, d, f in os.walk(path):
287 rr = os.path.relpath(r, root)
292 lfile = os.path.join(root, rr, ff)
293 if not os.path.islink(lfile):
295 ftp.chmod(ff, os.stat(lfile).st_mode)
296 for i in range(len(rr.split('/'))):
300 logging.info("Preparing server for build...")
301 serverpath = os.path.abspath(os.path.dirname(__file__))
302 ftp.put(os.path.join(serverpath, 'build.py'), 'build.py')
303 ftp.put(os.path.join(serverpath, 'common.py'), 'common.py')
304 ftp.put(os.path.join(serverpath, 'scanner.py'), 'scanner.py')
305 ftp.put(os.path.join(serverpath, 'net.py'), 'net.py')
306 ftp.put(os.path.join(serverpath, 'metadata.py'), 'metadata.py')
307 ftp.put(os.path.join(serverpath, '..', 'buildserver',
308 'config.buildserver.py'), 'config.py')
309 ftp.chmod('config.py', 0o600)
311 # Copy over the ID (head commit hash) of the fdroidserver in use...
312 subprocess.call('git rev-parse HEAD >' +
313 os.path.join(os.getcwd(), 'tmp', 'fdroidserverid'),
314 shell=True, cwd=serverpath)
315 ftp.put('tmp/fdroidserverid', 'fdroidserverid')
317 # Copy the metadata - just the file for this app...
318 ftp.mkdir('metadata')
320 ftp.chdir('metadata')
321 ftp.put(os.path.join('metadata', app['id'] + '.txt'),
323 # And patches if there are any...
324 if os.path.exists(os.path.join('metadata', app['id'])):
325 send_dir(os.path.join('metadata', app['id']))
328 # Create the build directory...
333 # Copy any extlibs that are required...
334 if thisbuild['extlibs']:
335 ftp.chdir(homedir + '/build/extlib')
336 for lib in thisbuild['extlibs']:
338 libsrc = os.path.join('build/extlib', lib)
339 if not os.path.exists(libsrc):
340 raise BuildException("Missing extlib {0}".format(libsrc))
343 if d not in ftp.listdir():
346 ftp.put(libsrc, lp[-1])
349 # Copy any srclibs that are required...
351 if thisbuild['srclibs']:
352 for lib in thisbuild['srclibs']:
354 common.getsrclib(lib, 'build/srclib', basepath=True, prepare=False))
356 # If one was used for the main source, add that too.
357 basesrclib = vcs.getsrclib()
359 srclibpaths.append(basesrclib)
360 for name, number, lib in srclibpaths:
361 logging.info("Sending srclib '%s'" % lib)
362 ftp.chdir(homedir + '/build/srclib')
363 if not os.path.exists(lib):
364 raise BuildException("Missing srclib directory '" + lib + "'")
365 fv = '.fdroidvcs-' + name
366 ftp.put(os.path.join('build/srclib', fv), fv)
368 # Copy the metadata file too...
369 ftp.chdir(homedir + '/srclibs')
370 ftp.put(os.path.join('srclibs', name + '.txt'),
372 # Copy the main app source code
373 # (no need if it's a srclib)
374 if (not basesrclib) and os.path.exists(build_dir):
375 ftp.chdir(homedir + '/build')
376 fv = '.fdroidvcs-' + app['id']
377 ftp.put(os.path.join('build', fv), fv)
380 # Execute the build script...
381 logging.info("Starting build...")
382 chan = sshs.get_transport().open_session()
384 cmdline = 'python build.py --on-server'
386 cmdline += ' --force --test'
388 cmdline += ' --verbose'
389 cmdline += " %s:%s" % (app['id'], thisbuild['vercode'])
390 chan.exec_command('bash -c ". ~/.bsenv && ' + cmdline + '"')
392 while not chan.exit_status_ready():
393 while chan.recv_ready():
394 output += chan.recv(1024)
396 logging.info("...getting exit status")
397 returncode = chan.recv_exit_status()
399 get = chan.recv(1024)
404 raise BuildException(
405 "Build.py failed on server for {0}:{1}".format(
406 app['id'], thisbuild['version']), output)
408 # Retrieve the built files...
409 logging.info("Retrieving build output...")
411 ftp.chdir(homedir + '/tmp')
413 ftp.chdir(homedir + '/unsigned')
414 apkfile = common.getapkname(app, thisbuild)
415 tarball = common.getsrcname(app, thisbuild)
417 ftp.get(apkfile, os.path.join(output_dir, apkfile))
418 if not options.notarball:
419 ftp.get(tarball, os.path.join(output_dir, tarball))
421 raise BuildException(
422 "Build failed for %s:%s - missing output files".format(
423 app['id'], thisbuild['version']), output)
428 # Suspend the build server.
432 def adapt_gradle(build_dir):
433 filename = 'build.gradle'
434 for root, dirs, files in os.walk(build_dir):
435 for filename in files:
436 if not filename.endswith('.gradle'):
438 path = os.path.join(root, filename)
439 if not os.path.isfile(path):
441 logging.debug("Adapting %s at %s" % (filename, path))
442 common.regsub_file(r"""(\s*)buildToolsVersion([\s=]+)['"].*""",
443 r"""\1buildToolsVersion\2'%s'""" % config['build_tools'],
447 def capitalize_intact(string):
448 """Like str.capitalize(), but leave the rest of the string intact without
449 switching it to lowercase."""
453 return string.upper()
454 return string[0].upper() + string[1:]
457 def build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh):
458 """Do a build locally."""
460 if thisbuild['buildjni'] and thisbuild['buildjni'] != ['no']:
461 if not thisbuild['ndk_path']:
462 logging.critical("Android NDK version '%s' could not be found!" % thisbuild['ndk'])
463 logging.critical("Configured versions:")
464 for k, v in config['ndk_paths'].iteritems():
465 if k.endswith("_orig"):
467 logging.critical(" %s: %s" % (k, v))
469 elif not os.path.isdir(thisbuild['ndk_path']):
470 logging.critical("Android NDK '%s' is not a directory!" % thisbuild['ndk_path'])
473 # Set up environment vars that depend on each build
474 for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
475 common.env[n] = thisbuild['ndk_path']
477 common.reset_env_path()
478 # Set up the current NDK to the PATH
479 common.add_to_env_path(thisbuild['ndk_path'])
481 # Prepare the source code...
482 root_dir, srclibpaths = common.prepare_source(vcs, app, thisbuild,
483 build_dir, srclib_dir,
484 extlib_dir, onserver, refresh)
486 # We need to clean via the build tool in case the binary dirs are
487 # different from the default ones
490 if thisbuild['type'] == 'maven':
491 logging.info("Cleaning Maven project...")
492 cmd = [config['mvn3'], 'clean', '-Dandroid.sdk.path=' + config['sdk_path']]
494 if '@' in thisbuild['maven']:
495 maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@', 1)[1])
496 maven_dir = os.path.normpath(maven_dir)
500 p = FDroidPopen(cmd, cwd=maven_dir)
502 elif thisbuild['type'] == 'gradle':
504 logging.info("Cleaning Gradle project...")
506 if thisbuild['preassemble']:
507 gradletasks += thisbuild['preassemble']
509 flavours = thisbuild['gradle']
510 if flavours == ['yes']:
513 flavours_cmd = ''.join([capitalize_intact(f) for f in flavours])
515 gradletasks += ['assemble' + flavours_cmd + 'Release']
517 adapt_gradle(build_dir)
518 for name, number, libpath in srclibpaths:
519 adapt_gradle(libpath)
521 cmd = [config['gradle']]
522 if thisbuild['gradleprops']:
523 cmd += ['-P'+kv for kv in thisbuild['gradleprops']]
525 for task in gradletasks:
526 parts = task.split(':')
527 parts[-1] = 'clean' + capitalize_intact(parts[-1])
528 cmd += [':'.join(parts)]
532 p = FDroidPopen(cmd, cwd=root_dir)
534 elif thisbuild['type'] == 'kivy':
537 elif thisbuild['type'] == 'ant':
538 logging.info("Cleaning Ant project...")
539 p = FDroidPopen(['ant', 'clean'], cwd=root_dir)
541 if p is not None and p.returncode != 0:
542 raise BuildException("Error cleaning %s:%s" %
543 (app['id'], thisbuild['version']), p.output)
545 for root, dirs, files in os.walk(build_dir):
546 # Don't remove possibly necessary 'gradle' dirs if 'gradlew' is not there
547 if 'gradlew' in files:
548 logging.debug("Getting rid of Gradle wrapper stuff in %s" % root)
549 os.remove(os.path.join(root, 'gradlew'))
550 if 'gradlew.bat' in files:
551 os.remove(os.path.join(root, 'gradlew.bat'))
553 shutil.rmtree(os.path.join(root, 'gradle'))
556 if thisbuild['scandelete']:
557 raise BuildException("Refusing to skip source scan since scandelete is present")
559 # Scan before building...
560 logging.info("Scanning source for common problems...")
561 count = scanner.scan_source(build_dir, root_dir, thisbuild)
564 logging.warn('Scanner found %d problems' % count)
566 raise BuildException("Can't build due to %d errors while scanning" % count)
568 if not options.notarball:
569 # Build the source tarball right before we build the release...
570 logging.info("Creating source tarball...")
571 tarname = common.getsrcname(app, thisbuild)
572 tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
575 return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
576 tarball.add(build_dir, tarname, exclude=tarexc)
579 # Run a build command if one is required...
580 if thisbuild['build']:
581 logging.info("Running 'build' commands in %s" % root_dir)
582 cmd = common.replace_config_vars(thisbuild['build'], thisbuild)
584 # Substitute source library paths into commands...
585 for name, number, libpath in srclibpaths:
586 libpath = os.path.relpath(libpath, root_dir)
587 cmd = cmd.replace('$$' + name + '$$', libpath)
589 p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
591 if p.returncode != 0:
592 raise BuildException("Error running build command for %s:%s" %
593 (app['id'], thisbuild['version']), p.output)
595 # Build native stuff if required...
596 if thisbuild['buildjni'] and thisbuild['buildjni'] != ['no']:
597 logging.info("Building the native code")
598 jni_components = thisbuild['buildjni']
600 if jni_components == ['yes']:
601 jni_components = ['']
602 cmd = [os.path.join(thisbuild['ndk_path'], "ndk-build"), "-j1"]
603 for d in jni_components:
605 logging.info("Building native code in '%s'" % d)
607 logging.info("Building native code in the main project")
608 manifest = os.path.join(root_dir, d, 'AndroidManifest.xml')
609 if os.path.exists(manifest):
610 # Read and write the whole AM.xml to fix newlines and avoid
611 # the ndk r8c or later 'wordlist' errors. The outcome of this
612 # under gnu/linux is the same as when using tools like
613 # dos2unix, but the native python way is faster and will
614 # work in non-unix systems.
615 manifest_text = open(manifest, 'U').read()
616 open(manifest, 'w').write(manifest_text)
617 # In case the AM.xml read was big, free the memory
619 p = FDroidPopen(cmd, cwd=os.path.join(root_dir, d))
620 if p.returncode != 0:
621 raise BuildException("NDK build failed for %s:%s" % (app['id'], thisbuild['version']), p.output)
624 # Build the release...
625 if thisbuild['type'] == 'maven':
626 logging.info("Building Maven project...")
628 if '@' in thisbuild['maven']:
629 maven_dir = os.path.join(root_dir, thisbuild['maven'].split('@', 1)[1])
633 mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
634 '-Dmaven.jar.sign.skip=true', '-Dmaven.test.skip=true',
635 '-Dandroid.sign.debug=false', '-Dandroid.release=true',
637 if thisbuild['target']:
638 target = thisbuild["target"].split('-')[1]
639 common.regsub_file(r'<platform>[0-9]*</platform>',
640 r'<platform>%s</platform>' % target,
641 os.path.join(root_dir, 'pom.xml'))
642 if '@' in thisbuild['maven']:
643 common.regsub_file(r'<platform>[0-9]*</platform>',
644 r'<platform>%s</platform>' % target,
645 os.path.join(maven_dir, 'pom.xml'))
647 p = FDroidPopen(mvncmd, cwd=maven_dir)
649 bindir = os.path.join(root_dir, 'target')
651 elif thisbuild['type'] == 'kivy':
652 logging.info("Building Kivy project...")
654 spec = os.path.join(root_dir, 'buildozer.spec')
655 if not os.path.exists(spec):
656 raise BuildException("Expected to find buildozer-compatible spec at {0}"
659 defaults = {'orientation': 'landscape', 'icon': '',
660 'permissions': '', 'android.api': "18"}
661 bconfig = ConfigParser(defaults, allow_no_value=True)
664 distdir = os.path.join('python-for-android', 'dist', 'fdroid')
665 if os.path.exists(distdir):
666 shutil.rmtree(distdir)
668 modules = bconfig.get('app', 'requirements').split(',')
670 cmd = 'ANDROIDSDK=' + config['sdk_path']
671 cmd += ' ANDROIDNDK=' + thisbuild['ndk_path']
672 cmd += ' ANDROIDNDKVER=' + thisbuild['ndk']
673 cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
674 cmd += ' VIRTUALENV=virtualenv'
675 cmd += ' ./distribute.sh'
676 cmd += ' -m ' + "'" + ' '.join(modules) + "'"
678 p = subprocess.Popen(cmd, cwd='python-for-android', shell=True)
679 if p.returncode != 0:
680 raise BuildException("Distribute build failed")
682 cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
684 raise BuildException("Package ID mismatch between metadata and spec")
686 orientation = bconfig.get('app', 'orientation', 'landscape')
687 if orientation == 'all':
688 orientation = 'sensor'
692 '--name', bconfig.get('app', 'title'),
693 '--package', app['id'],
694 '--version', bconfig.get('app', 'version'),
695 '--orientation', orientation
698 perms = bconfig.get('app', 'permissions')
699 for perm in perms.split(','):
700 cmd.extend(['--permission', perm])
702 if config.get('app', 'fullscreen') == 0:
703 cmd.append('--window')
705 icon = bconfig.get('app', 'icon.filename')
707 cmd.extend(['--icon', os.path.join(root_dir, icon)])
709 cmd.append('release')
710 p = FDroidPopen(cmd, cwd=distdir)
712 elif thisbuild['type'] == 'gradle':
713 logging.info("Building Gradle project...")
715 # Avoid having to use lintOptions.abortOnError false
716 if thisbuild['gradlepluginver'] >= LooseVersion('0.7'):
717 with open(os.path.join(root_dir, 'build.gradle'), "a") as f:
718 f.write("\nandroid { lintOptions { checkReleaseBuilds false } }\n")
720 cmd = [config['gradle']]
721 if thisbuild['gradleprops']:
722 cmd += ['-P'+kv for kv in thisbuild['gradleprops']]
726 p = FDroidPopen(cmd, cwd=root_dir)
728 elif thisbuild['type'] == 'ant':
729 logging.info("Building Ant project...")
731 if thisbuild['antcommands']:
732 cmd += thisbuild['antcommands']
735 p = FDroidPopen(cmd, cwd=root_dir)
737 bindir = os.path.join(root_dir, 'bin')
739 if p is not None and p.returncode != 0:
740 raise BuildException("Build failed for %s:%s" % (app['id'], thisbuild['version']), p.output)
741 logging.info("Successfully built version " + thisbuild['version'] + ' of ' + app['id'])
743 if thisbuild['type'] == 'maven':
744 stdout_apk = '\n'.join([
745 line for line in p.output.splitlines() if any(
746 a in line for a in ('.apk', '.ap_', '.jar'))])
747 m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
748 stdout_apk, re.S | re.M)
750 m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
751 stdout_apk, re.S | re.M)
753 m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
754 stdout_apk, re.S | re.M)
757 m = re.match(r".*^\[INFO\] Building jar: .*/" + bindir + r"/(.+)\.jar",
758 stdout_apk, re.S | re.M)
760 raise BuildException('Failed to find output')
762 src = os.path.join(bindir, src) + '.apk'
763 elif thisbuild['type'] == 'kivy':
764 src = os.path.join('python-for-android', 'dist', 'default', 'bin',
765 '{0}-{1}-release.apk'.format(
766 bconfig.get('app', 'title'),
767 bconfig.get('app', 'version')))
768 elif thisbuild['type'] == 'gradle':
770 if thisbuild['gradlepluginver'] >= LooseVersion('0.11'):
771 apks_dir = os.path.join(root_dir, 'build', 'outputs', 'apk')
773 apks_dir = os.path.join(root_dir, 'build', 'apk')
775 apks = glob.glob(os.path.join(apks_dir, '*-release-unsigned.apk'))
777 raise BuildException('More than one resulting apks found in %s' % apks_dir,
780 raise BuildException('Failed to find gradle output in %s' % apks_dir)
782 elif thisbuild['type'] == 'ant':
783 stdout_apk = '\n'.join([
784 line for line in p.output.splitlines() if '.apk' in line])
785 src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
786 re.S | re.M).group(1)
787 src = os.path.join(bindir, src)
788 elif thisbuild['type'] == 'raw':
789 src = os.path.join(root_dir, thisbuild['output'])
790 src = os.path.normpath(src)
792 # Make sure it's not debuggable...
793 if common.isApkDebuggable(src, config):
794 raise BuildException("APK is debuggable")
796 # By way of a sanity check, make sure the version and version
797 # code in our new apk match what we expect...
798 logging.debug("Checking " + src)
799 if not os.path.exists(src):
800 raise BuildException("Unsigned apk is not at expected location of " + src)
802 p = SdkToolsPopen(['aapt', 'dump', 'badging', src], output=False)
808 for line in p.output.splitlines():
809 if line.startswith("package:"):
810 pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
814 pat = re.compile(".*versionCode='([0-9]*)'.*")
818 pat = re.compile(".*versionName='([^']*)'.*")
822 elif line.startswith("native-code:"):
823 nativecode = line[12:]
825 # Ignore empty strings or any kind of space/newline chars that we don't
827 if nativecode is not None:
828 nativecode = nativecode.strip()
829 nativecode = None if not nativecode else nativecode
831 if thisbuild['buildjni'] and thisbuild['buildjni'] != ['no']:
832 if nativecode is None:
833 raise BuildException("Native code should have been built but none was packaged")
834 if thisbuild['novcheck']:
835 vercode = thisbuild['vercode']
836 version = thisbuild['version']
837 if not version or not vercode:
838 raise BuildException("Could not find version information in build in output")
840 raise BuildException("Could not find package ID in output")
841 if foundid != app['id']:
842 raise BuildException("Wrong package ID - build " + foundid + " but expected " + app['id'])
844 # Some apps (e.g. Timeriffic) have had the bonkers idea of
845 # including the entire changelog in the version number. Remove
846 # it so we can compare. (TODO: might be better to remove it
847 # before we compile, in fact)
848 index = version.find(" //")
850 version = version[:index]
852 if (version != thisbuild['version'] or
853 vercode != thisbuild['vercode']):
854 raise BuildException(("Unexpected version/version code in output;"
855 " APK: '%s' / '%s', "
856 " Expected: '%s' / '%s'")
857 % (version, str(vercode), thisbuild['version'],
858 str(thisbuild['vercode']))
861 # Add information for 'fdroid verify' to be able to reproduce the build
864 metadir = os.path.join(tmp_dir, 'META-INF')
865 if not os.path.exists(metadir):
867 homedir = os.path.expanduser('~')
868 for fn in ['buildserverid', 'fdroidserverid']:
869 shutil.copyfile(os.path.join(homedir, fn),
870 os.path.join(metadir, fn))
871 subprocess.call(['jar', 'uf', os.path.abspath(src),
872 'META-INF/' + fn], cwd=tmp_dir)
874 # Copy the unsigned apk to our destination directory for further
875 # processing (by publish.py)...
876 dest = os.path.join(output_dir, common.getapkname(app, thisbuild))
877 shutil.copyfile(src, dest)
879 # Move the source tarball into the output directory...
880 if output_dir != tmp_dir and not options.notarball:
881 shutil.move(os.path.join(tmp_dir, tarname),
882 os.path.join(output_dir, tarname))
885 def trybuild(app, thisbuild, build_dir, output_dir, also_check_dir, srclib_dir, extlib_dir,
886 tmp_dir, repo_dir, vcs, test, server, force, onserver, refresh):
888 Build a particular version of an application, if it needs building.
890 :param output_dir: The directory where the build output will go. Usually
891 this is the 'unsigned' directory.
892 :param repo_dir: The repo directory - used for checking if the build is
894 :paaram also_check_dir: An additional location for checking if the build
895 is necessary (usually the archive repo)
896 :param test: True if building in test mode, in which case the build will
897 always happen, even if the output already exists. In test mode, the
898 output directory should be a temporary location, not any of the real
901 :returns: True if the build was done, False if it wasn't necessary.
904 dest_apk = common.getapkname(app, thisbuild)
906 dest = os.path.join(output_dir, dest_apk)
907 dest_repo = os.path.join(repo_dir, dest_apk)
910 if os.path.exists(dest) or os.path.exists(dest_repo):
914 dest_also = os.path.join(also_check_dir, dest_apk)
915 if os.path.exists(dest_also):
918 if thisbuild['disable'] and not options.force:
921 logging.info("Building version %s (%s) of %s" % (
922 thisbuild['version'], thisbuild['vercode'], app['id']))
925 # When using server mode, still keep a local cache of the repo, by
926 # grabbing the source now.
927 vcs.gotorevision(thisbuild['commit'])
929 build_server(app, thisbuild, vcs, build_dir, output_dir, force)
931 build_local(app, thisbuild, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh)
935 def parse_commandline():
936 """Parse the command line. Returns options, parser."""
938 parser = ArgumentParser(usage="%(prog)s [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
939 common.setup_global_opts(parser)
940 parser.add_argument("appid", nargs='*', help="app-id with optional versioncode in the form APPID[:VERCODE]")
941 parser.add_argument("-l", "--latest", action="store_true", default=False,
942 help="Build only the latest version of each package")
943 parser.add_argument("-s", "--stop", action="store_true", default=False,
944 help="Make the build stop on exceptions")
945 parser.add_argument("-t", "--test", action="store_true", default=False,
946 help="Test mode - put output in the tmp directory only, and always build, even if the output already exists.")
947 parser.add_argument("--server", action="store_true", default=False,
948 help="Use build server")
949 parser.add_argument("--resetserver", action="store_true", default=False,
950 help="Reset and create a brand new build server, even if the existing one appears to be ok.")
951 parser.add_argument("--on-server", dest="onserver", action="store_true", default=False,
952 help="Specify that we're running on the build server")
953 parser.add_argument("--skip-scan", dest="skipscan", action="store_true", default=False,
954 help="Skip scanning the source code for binaries and other problems")
955 parser.add_argument("--no-tarball", dest="notarball", action="store_true", default=False,
956 help="Don't create a source tarball, useful when testing a build")
957 parser.add_argument("--no-refresh", dest="refresh", action="store_false", default=True,
958 help="Don't refresh the repository, useful when testing a build with no internet connection")
959 parser.add_argument("-f", "--force", action="store_true", default=False,
960 help="Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode.")
961 parser.add_argument("-a", "--all", action="store_true", default=False,
962 help="Build all applications available")
963 parser.add_argument("-w", "--wiki", default=False, action="store_true",
964 help="Update the wiki")
965 options = parser.parse_args()
967 # Force --stop with --on-server to get correct exit code
971 if options.force and not options.test:
972 parser.error("option %s: Force is only allowed in test mode" % "force")
974 return options, parser
982 global options, config
984 options, parser = parse_commandline()
985 if not options.appid and not options.all:
986 parser.error("option %s: If you really want to build all the apps, use --all" % "all")
988 config = common.read_config(options)
990 if config['build_server_always']:
991 options.server = True
992 if options.resetserver and not options.server:
993 parser.error("option %s: Using --resetserver without --server makes no sense" % "resetserver")
996 if not os.path.isdir(log_dir):
997 logging.info("Creating log directory")
1001 if not os.path.isdir(tmp_dir):
1002 logging.info("Creating temporary directory")
1003 os.makedirs(tmp_dir)
1006 output_dir = tmp_dir
1008 output_dir = 'unsigned'
1009 if not os.path.isdir(output_dir):
1010 logging.info("Creating output directory")
1011 os.makedirs(output_dir)
1013 if config['archive_older'] != 0:
1014 also_check_dir = 'archive'
1016 also_check_dir = None
1021 if not os.path.isdir(build_dir):
1022 logging.info("Creating build directory")
1023 os.makedirs(build_dir)
1024 srclib_dir = os.path.join(build_dir, 'srclib')
1025 extlib_dir = os.path.join(build_dir, 'extlib')
1027 # Read all app and srclib metadata
1028 allapps = metadata.read_metadata(xref=not options.onserver)
1030 apps = common.read_app_args(options.appid, allapps, True)
1031 for appid, app in apps.items():
1032 if (app['Disabled'] and not options.force) or not app['Repo Type'] or not app['builds']:
1036 raise FDroidException("No apps to process.")
1039 for app in apps.itervalues():
1040 for build in reversed(app['builds']):
1041 if build['disable'] and not options.force:
1043 app['builds'] = [build]
1048 site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
1049 path=config['wiki_path'])
1050 site.login(config['wiki_user'], config['wiki_password'])
1052 # Build applications...
1054 build_succeeded = []
1055 for appid, app in apps.iteritems():
1059 for thisbuild in app['builds']:
1063 # For the first build of a particular app, we need to set up
1064 # the source repo. We can reuse it on subsequent builds, if
1067 if app['Repo Type'] == 'srclib':
1068 build_dir = os.path.join('build', 'srclib', app['Repo'])
1070 build_dir = os.path.join('build', appid)
1072 # Set up vcs interface and make sure we have the latest code...
1073 logging.debug("Getting {0} vcs interface for {1}"
1074 .format(app['Repo Type'], app['Repo']))
1075 vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
1079 logging.debug("Checking " + thisbuild['version'])
1080 if trybuild(app, thisbuild, build_dir, output_dir,
1081 also_check_dir, srclib_dir, extlib_dir,
1082 tmp_dir, repo_dir, vcs, options.test,
1083 options.server, options.force,
1084 options.onserver, options.refresh):
1086 if app.get('Binaries', None):
1087 # This is an app where we build from source, and
1088 # verify the apk contents against a developer's
1089 # binary. We get that binary now, and save it
1090 # alongside our built one in the 'unsigend'
1092 url = app['Binaries']
1093 url = url.replace('%v', thisbuild['version'])
1094 url = url.replace('%c', str(thisbuild['vercode']))
1095 logging.info("...retrieving " + url)
1096 of = "{0}_{1}.apk.binary".format(app['id'], thisbuild['vercode'])
1097 of = os.path.join(output_dir, of)
1098 net.download_file(url, local_filename=of)
1100 build_succeeded.append(app)
1101 wikilog = "Build succeeded"
1102 except BuildException as be:
1103 with open(os.path.join(log_dir, appid + '.log'), 'a+') as f:
1105 print("Could not build app %s due to BuildException: %s" % (appid, be))
1108 failed_apps[appid] = be
1109 wikilog = be.get_wikitext()
1110 except VCSException as vcse:
1111 reason = str(vcse).split('\n', 1)[0] if options.verbose else str(vcse)
1112 logging.error("VCS error while building app %s: %s" % (
1116 failed_apps[appid] = vcse
1118 except Exception as e:
1119 logging.error("Could not build app %s due to unknown error: %s" % (
1120 appid, traceback.format_exc()))
1123 failed_apps[appid] = e
1126 if options.wiki and wikilog:
1128 # Write a page with the last build log for this version code
1129 lastbuildpage = appid + '/lastbuild_' + thisbuild['vercode']
1130 newpage = site.Pages[lastbuildpage]
1131 txt = "Build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + "\n\n" + wikilog
1132 newpage.save(txt, summary='Build log')
1133 # Redirect from /lastbuild to the most recent build log
1134 newpage = site.Pages[appid + '/lastbuild']
1135 newpage.save('#REDIRECT [[' + lastbuildpage + ']]', summary='Update redirect')
1137 logging.error("Error while attempting to publish build log")
1139 for app in build_succeeded:
1140 logging.info("success: %s" % (app['id']))
1142 if not options.verbose:
1143 for fa in failed_apps:
1144 logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
1146 logging.info("Finished.")
1147 if len(build_succeeded) > 0:
1148 logging.info(str(len(build_succeeded)) + ' builds succeeded')
1149 if len(failed_apps) > 0:
1150 logging.info(str(len(failed_apps)) + ' builds failed')
1154 if __name__ == "__main__":